tor

The Tor anonymity network
git clone https://git.dasho.dev/tor.git
Log | Files | Refs | README | LICENSE

commit fd3da15d2a0e8806f407c24060c660122da83da7
parent c217699db1c5be02a59d7367decdb92b32827210
Author: David Goulet <dgoulet@torproject.org>
Date:   Wed, 22 Jan 2025 09:06:14 -0500

Merge branch 'maint-0.4.8'

Diffstat:
Achanges/ticket40996 | 5+++++
Msrc/core/or/relay.c | 2+-
Msrc/feature/dircache/dircache.c | 13+++++++++++++
Msrc/feature/dircommon/dir_connection_st.h | 4+++-
Msrc/feature/dircommon/directory.c | 12++++++++++++
Msrc/feature/hs/hs_cache.c | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/feature/hs/hs_cache.h | 16+++++++++++++++-
Msrc/feature/hs/hs_ident.c | 10++++++++++
Msrc/feature/hs/hs_ident.h | 2++
Msrc/test/test_hs_cache.c | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
10 files changed, 229 insertions(+), 36 deletions(-)

diff --git a/changes/ticket40996 b/changes/ticket40996 @@ -0,0 +1,5 @@ + o Major bugfixes (onion service directory cache): + - When the OOM killer kicks in, cleanup the descriptor cache of an HSDir by + looking at the lowest downloaded count instead of time in cache. Fixes bug + 40996; bugfix on 0.3.5.1-alpha. + diff --git a/src/core/or/relay.c b/src/core/or/relay.c @@ -2940,7 +2940,7 @@ cell_queues_check_size(void) if (hs_cache_total > get_options()->MaxMemInQueues / 5) { const size_t bytes_to_remove = hs_cache_total - (size_t)(get_options()->MaxMemInQueues / 10); - removed = hs_cache_handle_oom(now, bytes_to_remove); + removed = hs_cache_handle_oom(bytes_to_remove); oom_stats_n_bytes_removed_hsdir += removed; alloc -= removed; } diff --git a/src/feature/dircache/dircache.c b/src/feature/dircache/dircache.c @@ -26,6 +26,7 @@ #include "feature/dircommon/directory.h" #include "feature/dircommon/fp_pair.h" #include "feature/hs/hs_cache.h" +#include "feature/hs/hs_ident.h" #include "feature/nodelist/authcert.h" #include "feature/nodelist/networkstatus.h" #include "feature/nodelist/routerlist.h" @@ -34,6 +35,7 @@ #include "feature/stats/geoip_stats.h" #include "feature/stats/rephist.h" #include "lib/compress/compress.h" +#include "lib/crypt_ops/crypto_format.h" #include "feature/dircache/cached_dir_st.h" #include "feature/dircommon/dir_connection_st.h" @@ -1381,6 +1383,17 @@ handle_get_hs_descriptor_v3(dir_connection_t *conn, write_http_response_header(conn, strlen(desc_str), NO_METHOD, 0); connection_buf_add(desc_str, strlen(desc_str), TO_CONN(conn)); + /* We have successfully written the descriptor on the connection outbuf so + * save this query identifier into the dir_connection_t in order + * to associate it to the descriptor when closing. */ + { + /* Decode blinded key. This is certain to work else + * hs_cache_lookup_as_dir() would have failed. */ + ed25519_public_key_t blinded_key; + ed25519_public_from_base64(&blinded_key, pubkey_str); + conn->hs_ident = hs_ident_server_dir_conn_new(&blinded_key); + } + done: return 0; } diff --git a/src/feature/dircommon/dir_connection_st.h b/src/feature/dircommon/dir_connection_st.h @@ -44,7 +44,9 @@ struct dir_connection_t { /* Hidden service connection identifier for dir connections: Used by HS client-side code to fetch HS descriptors, and by the service-side code to - upload descriptors. */ + upload descriptors. Also used by the HSDir, setting only the blinded key, + in order to locate back the descriptor in the cache once the dir stream is + closed. */ struct hs_ident_dir_conn_t *hs_ident; /** If this is a one-hop connection, tracks the state of the directory guard diff --git a/src/feature/dircommon/directory.c b/src/feature/dircommon/directory.c @@ -16,6 +16,7 @@ #include "feature/dirclient/dirclient.h" #include "feature/dircommon/directory.h" #include "feature/dircommon/fp_pair.h" +#include "feature/hs/hs_cache.h" #include "feature/stats/geoip_stats.h" #include "lib/compress/compress.h" @@ -492,6 +493,17 @@ connection_dir_about_to_close(dir_connection_t *dir_conn) connection_dir_client_request_failed(dir_conn); } + /* If we are an HSDir, mark the corresponding descriptor as downloaded. This + * is needed for the OOM cache cleanup. + * + * This is done when the direction connection is closed in order to raise the + * attack cost of filling the cache with bogus descriptors. That attacker + * would need to increase that downloaded counter for the attack to be + * successful which is expensive. */ + if (conn->purpose == DIR_PURPOSE_SERVER && dir_conn->hs_ident) { + hs_cache_mark_dowloaded_as_dir(dir_conn->hs_ident); + } + connection_dir_client_refetch_hsdesc_if_needed(dir_conn); } diff --git a/src/feature/hs/hs_cache.c b/src/feature/hs/hs_cache.c @@ -68,7 +68,7 @@ store_v3_desc_as_dir(hs_cache_dir_descriptor_t *desc) } /** Query our cache and return the entry or NULL if not found. */ -static hs_cache_dir_descriptor_t * +STATIC hs_cache_dir_descriptor_t * lookup_v3_desc_as_dir(const uint8_t *key) { tor_assert(key); @@ -224,6 +224,71 @@ cache_lookup_v3_as_dir(const char *query, const char **desc_out) return -1; } +/** Clean the v3 cache by removing entries that are below or equal the + * downloaded target. + * + * Stop when the max_remove_bytes is reached. It is possible that more bytes + * are removed if max_remove_bytes is not aligned on cache entry size. + * + * Return the amount of bytes freed. The next_lowest param is set to the + * lowest n_downloaded value in the cache that is above target. + * + * If both next_lowest and returned value are 0, the cache is empty. */ +STATIC size_t +cache_clean_v3_by_downloaded_as_dir(const uint64_t target, + const size_t max_remove_bytes, + uint64_t *next_lowest) +{ + size_t bytes_removed = 0; + uint64_t lowest = 0; + + if (!hs_cache_v3_dir) { /* No cache to clean. Just return. */ + goto end; + } + + log_info(LD_REND, "Cleaning HS cache for downloaded target of %" PRIu64 + ". Maximum bytes to removed: %zu", + target, max_remove_bytes); + + DIGEST256MAP_FOREACH_MODIFY(hs_cache_v3_dir, key, + hs_cache_dir_descriptor_t *, entry) { + /* Downloaded counter is above target, ignore. Record next lowest. */ + if (entry->n_downloaded > target) { + if (lowest == 0 || lowest > entry->n_downloaded) { + lowest = entry->n_downloaded; + } + continue; + } + /* We have removed enough, avoid cleaning this entry. Reason we continue + * the loop is so we can find the next lowest value. Yes, with many + * entries, this could be expensive but this is only called during OOM + * cleanup which should be fairly rare. */ + if (bytes_removed >= max_remove_bytes) { + continue; + } + size_t entry_size = cache_get_dir_entry_size(entry); + /* Logging. */ + { + char key_b64[BASE64_DIGEST256_LEN + 1]; + digest256_to_base64(key_b64, (const char *) key); + log_info(LD_REND, "Removing v3 descriptor '%s' from HSDir cache. " + "Downloaded %" PRIu64 " times and size of %zu bytes", + safe_str_client(key_b64), entry->n_downloaded, entry_size); + } + /* Remove it from our cache. */ + MAP_DEL_CURRENT(key); + bytes_removed += entry_size; + /* Entry is not in the cache anymore, destroy it. */ + cache_dir_desc_free(entry); + /* Update our cache entry allocation size for the OOM. */ + hs_cache_decrement_allocation(entry_size); + } DIGEST256MAP_FOREACH_END; + + end: + *next_lowest = lowest; + return bytes_removed; +} + /** Clean the v3 cache by removing any entry that has expired using the * <b>global_cutoff</b> value. If <b>global_cutoff</b> is 0, the cleaning * process will use the lifetime found in the plaintext data section. Return @@ -334,6 +399,22 @@ hs_cache_lookup_as_dir(uint32_t version, const char *query, return found; } +/** Using the given directory identifier, lookup the descriptor in our cache + * and if present, increment the downloaded counter. This is done when the + * directory connection fetching this descriptor is closed. */ +void +hs_cache_mark_dowloaded_as_dir(const hs_ident_dir_conn_t *ident) +{ + hs_cache_dir_descriptor_t *entry; + + tor_assert(ident); + + entry = lookup_v3_desc_as_dir(ident->blinded_pk.pubkey); + if (entry) { + entry->n_downloaded++; + } +} + /** Clean all directory caches using the current time now. */ void hs_cache_clean_as_dir(time_t now) @@ -1071,45 +1152,28 @@ hs_cache_client_new_auth_parse(const ed25519_public_key_t *service_pk) * min_remove_bytes if the caches get emptied out so the caller should be * aware of this. */ size_t -hs_cache_handle_oom(time_t now, size_t min_remove_bytes) +hs_cache_handle_oom(size_t min_remove_bytes) { - time_t k; size_t bytes_removed = 0; + /* The downloaded counter value to remove. Start at 0 and increment to the + * next lowest value in the cache. */ + uint64_t target = 0; /* Our OOM handler called with 0 bytes to remove is a code flow error. */ tor_assert(min_remove_bytes != 0); - /* The algorithm is as follow. K is the oldest expected descriptor age. - * - * 1) Deallocate all entries from v3 cache that are older than K hours - * 2.1) If the amount of remove bytes has been reached, stop. - * 2) Set K = K - 1 hour and repeat process until K is < 0. - * - * This ends up being O(Kn). - */ - - /* Set K to the oldest expected age in seconds which is the maximum - * lifetime of a cache entry. */ - k = hs_cache_max_entry_lifetime(); - + /* Loop until we have an empty cache or we have removed enough bytes. */ do { - time_t cutoff; - - /* If K becomes negative, it means we've empty the caches so stop and - * return what we were able to cleanup. */ - if (k < 0) { + /* This is valid due to the loop condition. At the start, min_remove_bytes + * can't be 0. */ + size_t bytes_to_free = (min_remove_bytes - bytes_removed); + size_t bytes_freed = + cache_clean_v3_by_downloaded_as_dir(target, bytes_to_free, &target); + if (bytes_freed == 0 && target == 0) { + /* Indicate that the cache is empty. */ break; } - /* Compute a cutoff value with K and the current time. */ - cutoff = now - k; - - if (bytes_removed < min_remove_bytes) { - /* We haven't remove enough bytes so clean v3 cache. */ - bytes_removed += cache_clean_v3_as_dir(now, cutoff); - /* Decrement K by a post period to shorten the cutoff, Two minutes - * if we are a testing network, or one hour otherwise. */ - k -= get_options()->TestingTorNetwork ? 120 : 3600; - } + bytes_removed += bytes_freed; } while (bytes_removed < min_remove_bytes); return bytes_removed; @@ -1194,3 +1258,17 @@ hs_cache_increment_allocation(size_t n) } } } + +#ifdef TOR_UNIT_TESTS + +/** Test only: Set the downloaded counter value of a HSDir cache entry. */ +void +dir_set_downloaded(const ed25519_public_key_t *pk, uint64_t value) +{ + hs_cache_dir_descriptor_t *entry = lookup_v3_desc_as_dir(pk->pubkey); + if (entry) { + entry->n_downloaded = value; + } +} + +#endif /* TOR_UNIT_TESTS */ diff --git a/src/feature/hs/hs_cache.h b/src/feature/hs/hs_cache.h @@ -13,6 +13,7 @@ #include "feature/hs/hs_common.h" #include "feature/hs/hs_descriptor.h" +#include "feature/hs/hs_ident.h" #include "feature/rend/rendcommon.h" #include "feature/nodelist/torcert.h" @@ -68,6 +69,10 @@ typedef struct hs_cache_dir_descriptor_t { /** Encoded descriptor which is basically in text form. It's a NUL terminated * string thus safe to strlen(). */ char *encoded_desc; + /** How many times this descriptor has been downloaded. We use this as an + * heuristic for the OOM cache cleaning. It is very large so we avoid an kind + * of possible wrapping. */ + uint64_t n_downloaded; } hs_cache_dir_descriptor_t; /* Public API */ @@ -82,7 +87,7 @@ hs_cache_max_entry_lifetime(void) void hs_cache_init(void); void hs_cache_free_all(void); void hs_cache_clean_as_dir(time_t now); -size_t hs_cache_handle_oom(time_t now, size_t min_remove_bytes); +size_t hs_cache_handle_oom(size_t min_remove_bytes); unsigned int hs_cache_get_max_descriptor_size(void); @@ -92,6 +97,7 @@ unsigned int hs_cache_get_max_descriptor_size(void); int hs_cache_store_as_dir(const char *desc); int hs_cache_lookup_as_dir(uint32_t version, const char *query, const char **desc_out); +void hs_cache_mark_dowloaded_as_dir(const hs_ident_dir_conn_t *ident); const hs_descriptor_t * hs_cache_lookup_as_client(const struct ed25519_public_key_t *key); @@ -144,10 +150,18 @@ typedef struct hs_cache_client_descriptor_t { } hs_cache_client_descriptor_t; STATIC size_t cache_clean_v3_as_dir(time_t now, time_t global_cutoff); +STATIC size_t cache_clean_v3_by_downloaded_as_dir(const uint64_t target, + const size_t min_remove_bytes, + uint64_t *next_lowest); +STATIC hs_cache_dir_descriptor_t *lookup_v3_desc_as_dir(const uint8_t *key); STATIC hs_cache_client_descriptor_t * lookup_v3_desc_as_client(const uint8_t *key); +#ifdef TOR_UNIT_TESTS +void dir_set_downloaded(const ed25519_public_key_t *pk, uint64_t value); +#endif /* TOR_UNIT_TESTS */ + #endif /* defined(HS_CACHE_PRIVATE) */ #endif /* !defined(TOR_HS_CACHE_H) */ diff --git a/src/feature/hs/hs_ident.c b/src/feature/hs/hs_ident.c @@ -62,6 +62,16 @@ hs_ident_dir_conn_free_(hs_ident_dir_conn_t *ident) tor_free(ident); } +/** Return a newly allocated HS directory connection identifier that is meant + * for the server side (HSDir). Only the blinded key is known by the HSDir. */ +hs_ident_dir_conn_t * +hs_ident_server_dir_conn_new(const ed25519_public_key_t *blinded_pk) +{ + hs_ident_dir_conn_t *ident = tor_malloc_zero(sizeof(*ident)); + ed25519_pubkey_copy(&ident->blinded_pk, blinded_pk); + return ident; +} + /** Initialized the allocated ident object with identity_pk and blinded_pk. * None of them can be NULL since a valid directory connection identifier must * have all fields set. */ diff --git a/src/feature/hs/hs_ident.h b/src/feature/hs/hs_ident.h @@ -128,6 +128,8 @@ void hs_ident_dir_conn_free_(hs_ident_dir_conn_t *ident); void hs_ident_dir_conn_init(const ed25519_public_key_t *identity_pk, const ed25519_public_key_t *blinded_pk, hs_ident_dir_conn_t *ident); +hs_ident_dir_conn_t *hs_ident_server_dir_conn_new( + const ed25519_public_key_t *blinded_pk); /* Edge connection identifier API. */ hs_ident_edge_conn_t *hs_ident_edge_conn_new( diff --git a/src/test/test_hs_cache.c b/src/test/test_hs_cache.c @@ -90,7 +90,7 @@ test_directory(void *arg) tt_str_op(desc_out, OP_EQ, desc1_str); /* Tell our OOM to run and to at least remove a byte which will result in * removing the descriptor from our cache. */ - oom_size = hs_cache_handle_oom(time(NULL), 1); + oom_size = hs_cache_handle_oom(1); tt_int_op(oom_size, OP_GE, 1); ret = hs_cache_lookup_as_dir(3, helper_get_hsdir_query(desc1), NULL); tt_int_op(ret, OP_EQ, 0); @@ -127,7 +127,7 @@ test_directory(void *arg) NULL); tt_int_op(ret, OP_EQ, 0); /* Cleanup our entire cache. */ - oom_size = hs_cache_handle_oom(time(NULL), 1); + oom_size = hs_cache_handle_oom(1); tt_int_op(oom_size, OP_GE, 1); hs_descriptor_free(desc_zero_lifetime); tor_free(desc_zero_lifetime_str); @@ -224,6 +224,61 @@ test_clean_as_dir(void *arg) tor_free(desc1_str); } +static void +test_clean_oom_as_dir(void *arg) +{ + size_t ret; + char *desc1_str = NULL, *desc2_str = NULL; + hs_descriptor_t *desc1 = NULL, *desc2 = NULL; + ed25519_keypair_t signing_kp1, signing_kp2; + + (void) arg; + + init_test(); + + /* Generate two valid descriptors. */ + ret = ed25519_keypair_generate(&signing_kp1, 0); + tt_int_op(ret, OP_EQ, 0); + ret = ed25519_keypair_generate(&signing_kp2, 0); + tt_int_op(ret, OP_EQ, 0); + desc1 = hs_helper_build_hs_desc_with_ip(&signing_kp1); + tt_assert(desc1); + desc2 = hs_helper_build_hs_desc_with_ip(&signing_kp2); + tt_assert(desc2); + ret = hs_desc_encode_descriptor(desc1, &signing_kp1, NULL, &desc1_str); + tt_int_op(ret, OP_EQ, 0); + ret = hs_cache_store_as_dir(desc1_str); + tt_int_op(ret, OP_EQ, 0); + ret = hs_desc_encode_descriptor(desc2, &signing_kp2, NULL, &desc2_str); + tt_int_op(ret, OP_EQ, 0); + ret = hs_cache_store_as_dir(desc2_str); + tt_int_op(ret, OP_EQ, 0); + + /* Set the downloaded count to 42 for the second one. */ + dir_set_downloaded(&desc2->plaintext_data.blinded_pubkey, 42); + const hs_cache_dir_descriptor_t *entry = + lookup_v3_desc_as_dir(desc2->plaintext_data.blinded_pubkey.pubkey); + tt_u64_op(entry->n_downloaded, OP_EQ, 42); + + /* Spin the OOM cleanup for only 1 descriptor (very low amount of bytes). We + * expect desc1 to be cleaned up because its downloaded counter is 0. */ + size_t removed = hs_cache_handle_oom(1); + tt_size_op(removed, OP_GT, 0); + + /* Desc1 is gone. */ + entry = lookup_v3_desc_as_dir(desc1->plaintext_data.blinded_pubkey.pubkey); + tt_assert(!entry); + /* Desc2 is still there. */ + entry = lookup_v3_desc_as_dir(desc2->plaintext_data.blinded_pubkey.pubkey); + tt_assert(entry); + + done: + hs_descriptor_free(desc1); + hs_descriptor_free(desc2); + tor_free(desc1_str); + tor_free(desc2_str); +} + /* Test helper: Fetch an HS descriptor from an HSDir (for the hidden service with <b>blinded_key</b>. Return the received descriptor string. */ static char * @@ -705,6 +760,8 @@ struct testcase_t hs_cache[] = { NULL, NULL }, { "clean_as_dir", test_clean_as_dir, TT_FORK, NULL, NULL }, + { "clean_oom_as_dir", test_clean_oom_as_dir, TT_FORK, + NULL, NULL }, { "hsdir_revision_counter_check", test_hsdir_revision_counter_check, TT_FORK, NULL, NULL }, { "upload_and_download_hs_desc", test_upload_and_download_hs_desc, TT_FORK,