From 49a477d4b79ca3c0460114cb953d9548983ed66a Mon Sep 17 00:00:00 2001 From: kedimuzafer Date: Sat, 29 Aug 2026 20:02:50 +0300 Subject: [PATCH] perf: cache the DISCO X25519 shared secret nacl_box()/nacl_box_open() derive the shared key with nacl_box_beforenm() on every call, which is an X25519 scalar multiplication. Measured on an ESP32-S3 at 240 MHz that is 30-40 ms, and DISCO does one per packet in each direction. The node's own disco secret key never changes at runtime, so the result depends only on the peer's public key. Adds nacl_box_shared_get(), a 24-slot memo keyed on the peer public key, and routes the four DISCO call sites in ml_wg_mgr.c through it plus the existing _afternm() primitives. Effect on a 15-peer tailnet: disco_periodic_probes() went from ~113 ms per one-second round to under a millisecond, and processing a single 110-byte DISCO PONG from 30-40 ms to negligible. Those 113 ms rounds ran on the same task that drains the WireGuard receive queue, so the queue overflowed behind them and tunnelled TCP could not stay up. 64 bytes of state per slot, 1.5 KB total, no allocation. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqLmUG7wJ7p9ya2Vp4A9J --- components/microlink/src/ml_wg_mgr.c | 26 +++++++++++------ components/microlink/src/nacl_box.c | 42 ++++++++++++++++++++++++++++ components/microlink/src/nacl_box.h | 19 +++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/components/microlink/src/ml_wg_mgr.c b/components/microlink/src/ml_wg_mgr.c index ed40942..57b8e01 100644 --- a/components/microlink/src/ml_wg_mgr.c +++ b/components/microlink/src/ml_wg_mgr.c @@ -672,8 +672,10 @@ static void disco_build_ping(microlink_t *ml, int peer_idx, /* Encrypt with NaCl box: our disco private key -> peer's disco public key */ uint8_t ciphertext[46 + NACL_BOX_MACBYTES]; - nacl_box(ciphertext, plaintext, sizeof(plaintext), nonce, - p->disco_key, ml->disco_private_key); + uint8_t disco_shared[32]; + nacl_box_shared_get(disco_shared, p->disco_key, ml->disco_private_key); + nacl_box_afternm(ciphertext, plaintext, sizeof(plaintext), nonce, + disco_shared); /* Build packet: magic(6) + our_disco_pubkey(32) + nonce(24) + ciphertext(62) = 124 bytes */ size_t pos = 0; @@ -733,8 +735,10 @@ static void disco_build_pong(microlink_t *ml, int peer_idx, /* Encrypt */ uint8_t ciphertext[32 + NACL_BOX_MACBYTES]; - nacl_box(ciphertext, plaintext, sizeof(plaintext), nonce, - p->disco_key, ml->disco_private_key); + uint8_t disco_shared[32]; + nacl_box_shared_get(disco_shared, p->disco_key, ml->disco_private_key); + nacl_box_afternm(ciphertext, plaintext, sizeof(plaintext), nonce, + disco_shared); /* Build packet */ size_t pos = 0; @@ -1038,8 +1042,13 @@ static void process_disco_packet(microlink_t *ml, const ml_rx_packet_t *pkt) { uint8_t *plaintext = malloc(plaintext_len); if (!plaintext) return; - if (nacl_box_open(plaintext, ciphertext, ciphertext_len, nonce, - sender_disco_key, ml->disco_private_key) != 0) { + /* Cached shared key: the X25519 inside nacl_box_open() costs ~30 ms and + * would otherwise run on every DISCO packet received. */ + uint8_t disco_shared[32]; + nacl_box_shared_get(disco_shared, sender_disco_key, ml->disco_private_key); + + if (nacl_box_open_afternm(plaintext, ciphertext, ciphertext_len, nonce, + disco_shared) != 0) { ESP_LOGW(TAG, "DISCO decrypt failed"); free(plaintext); return; @@ -1271,8 +1280,9 @@ static void disco_send_call_me_maybe(microlink_t *ml, int peer_idx) { esp_fill_random(nonce, DISCO_NONCE_LEN); uint8_t ciphertext[sizeof(plaintext) + NACL_BOX_MACBYTES]; - nacl_box(ciphertext, plaintext, pt_len, nonce, - p->disco_key, ml->disco_private_key); + uint8_t disco_shared[32]; + nacl_box_shared_get(disco_shared, p->disco_key, ml->disco_private_key); + nacl_box_afternm(ciphertext, plaintext, pt_len, nonce, disco_shared); /* Build packet: magic(6) + disco_pubkey(32) + nonce(24) + ciphertext */ uint8_t pkt[256]; diff --git a/components/microlink/src/nacl_box.c b/components/microlink/src/nacl_box.c index 93bfd5f..f6e8061 100644 --- a/components/microlink/src/nacl_box.c +++ b/components/microlink/src/nacl_box.c @@ -9,6 +9,7 @@ #include "nacl_box.h" #include "x25519.h" #include +#include /* Debug logging - set to 1 to enable */ #define NACL_BOX_DEBUG 0 @@ -540,6 +541,47 @@ int nacl_box_beforenm(uint8_t *shared_key, return 0; } +/* --------------------------------------------------------------------------- + * Shared-key cache + * + * Keyed on the peer public key; our secret key never changes at runtime, so a + * hit avoids the X25519 that would otherwise run on every DISCO packet. + * Entries are replaced round-robin - a tailnet has far fewer simultaneously + * active peers than slots, and a miss costs exactly what the old path did. + * ------------------------------------------------------------------------- */ + +#define NACL_SHARED_CACHE_SLOTS 24 + +static struct { + uint8_t peer_pk[32]; + uint8_t shared_key[32]; + bool valid; +} s_shared_cache[NACL_SHARED_CACHE_SLOTS]; + +static int s_shared_cache_next; + +int nacl_box_shared_get(uint8_t *shared_key, + const uint8_t *peer_pk, + const uint8_t *our_sk) { + for (int i = 0; i < NACL_SHARED_CACHE_SLOTS; i++) { + if (s_shared_cache[i].valid && + memcmp(s_shared_cache[i].peer_pk, peer_pk, 32) == 0) { + memcpy(shared_key, s_shared_cache[i].shared_key, 32); + return 0; + } + } + + int ret = nacl_box_beforenm(shared_key, peer_pk, our_sk); + if (ret != 0) return ret; + + int slot = s_shared_cache_next; + s_shared_cache_next = (s_shared_cache_next + 1) % NACL_SHARED_CACHE_SLOTS; + memcpy(s_shared_cache[slot].peer_pk, peer_pk, 32); + memcpy(s_shared_cache[slot].shared_key, shared_key, 32); + s_shared_cache[slot].valid = true; + return 0; +} + int nacl_box_afternm(uint8_t *ciphertext, const uint8_t *plaintext, size_t plaintext_len, const uint8_t *nonce, diff --git a/components/microlink/src/nacl_box.h b/components/microlink/src/nacl_box.h index 01172bc..5860abd 100644 --- a/components/microlink/src/nacl_box.h +++ b/components/microlink/src/nacl_box.h @@ -96,4 +96,23 @@ int nacl_box_open_afternm(uint8_t *plaintext, const uint8_t *nonce, const uint8_t *shared_key); +/** + * @brief Shared key for (peer_pk, our_sk), memoised across calls. + * + * nacl_box_beforenm() is an X25519 scalar multiplication, which costs roughly + * 30 ms on an ESP32-S3. DISCO performs one per packet in each direction, so on + * a tailnet of any size the WireGuard manager task spends most of its time + * recomputing the same handful of shared secrets. Our own secret key is fixed + * for the node's lifetime, so the result depends only on the peer's public key + * and can be cached. + * + * @param shared_key Output 32-byte shared key + * @param peer_pk Peer's public key (32 bytes) - the cache key + * @param our_sk Our secret key (32 bytes) + * @return 0 on success, -1 on failure + */ +int nacl_box_shared_get(uint8_t *shared_key, + const uint8_t *peer_pk, + const uint8_t *our_sk); + #endif /* _NACL_BOX_H_ */