Skip to content

Add fast path patch for ChaCha20-Poly1305 noise operations - #32

Merged
bdraco merged 4 commits into
mainfrom
noise-fast-path
Aug 18, 2026
Merged

Add fast path patch for ChaCha20-Poly1305 noise operations#32
bdraco merged 4 commits into
mainfrom
noise-fast-path

Conversation

@bdraco

@bdraco bdraco commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

Speeds up the ChaCha20-Poly1305 hot path used by the ESPHome noise API, split into an upstreamable patch and a port specific one.
The main consumer of this path today is log message streaming over the encrypted API, small messages where the per message fixed cost dominates, which is what these changes target. OTA over noise is planned eventually; that shifts the focus to bulk throughput, where larger noise frames amortize the fixed cost and a hand tuned chacha loop remains as a follow up.

The changes:

  1. Patch 05 (portable): the partial block tail in chacha20_encrypt_bytes and the poly1305 leftover buffering copy with memcpy instead of byte loops, and the tail block always takes the aligned path since it runs from the aligned stack buffer; this is the largest win for small messages, which are the common case for API traffic.
  2. Patch 06 plus port_include/sodium/sodium_esphome.h: a session persistent crypto_stream_chacha20_ietf_session_state whose key schedule is loaded once per session; crypto_stream_chacha20_ietf_session_block0_xor writes the Poly1305 key block as pure keystream (no memset, no xor with zeros) and encrypts the payload in the same pass; crypto_onetimeauth_poly1305_aead_mac computes the whole AEAD transcript in one call instead of five dispatched init/update/final calls. The declarations and capability macros live in the port owned header, so the upstream public headers are untouched and consumers can feature detect the port.

No IRAM placement; it measured no best case gain and IRAM is too scarce to spend on it.

Benchmarks

noise_cipherstate_encrypt with the same pattern as APINoiseFrameHelper::write_protobuf_messages; baseline is current main (patches 01 to 04) with noise-c main, after is this change together with the companion noise-c change.

ESP32 (Xtensa LX6, 240 MHz, ESP-IDF):

Size Before After
50 B 37.4 µs/op 27.4 µs/op (27% faster)
100 B 48.8 µs/op 38.9 µs/op (20% faster)
1000 B 234.3 µs/op 222.2 µs/op (5% faster)

ESP8266 (Xtensa LX106, 80 MHz, Arduino), same benchmark:

Size Before After
50 B 190.4 µs/op 160.2 µs/op (16% faster)
100 B 267.6 µs/op 240.8 µs/op (10% faster)
1000 B 1672.6 µs/op 1650.4 µs/op (1% faster)

Memory

Measured on the same builds: ESP32 flash grows 124 bytes and static RAM is unchanged; ESP8266 flash shrinks 956 bytes and static RAM shrinks 52 bytes (the fast path uses fewer libsodium entry points, so more code is dead stripped). Each cipher state grows 32 bytes (key schedule instead of raw key), 64 bytes per connection. Peak stack per crypto call drops from about 880 to about 528 bytes because the per operation scratch, the stacked cipher context and two wrapper frames are gone.

Verification

All patches apply cleanly and the patched tree matches the benchmarked tree byte for byte; an encrypted API session (handshake plus bidirectional traffic) works against the patched build on ESP32 and ESP8266; the session functions were verified to produce output byte identical to the two call composition on device.

Companion PR: esphome-libs/noise-c#26 uses the new functions behind a capability macro, with the current implementation kept as fallback for stock libsodium.

@esphbot

esphbot commented Aug 18, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Important issues found.

  • Capability macro is defined even when the patches are not applied, breaking the documented generic CMake path

@bdraco
bdraco marked this pull request as ready for review August 18, 2026 03:51
Copilot AI lite review requested due to automatic review settings August 18, 2026 03:51

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an ESPHome-specific “fast path” for the Noise protocol’s ChaCha20-Poly1305 operations on constrained MCUs, focused on reducing fixed per-message overhead (especially for small encrypted API frames).

Changes:

  • Adds a port-owned public header (sodium_esphome.h) that exposes capability macros and fast-path APIs without changing upstream libsodium public headers.
  • Introduces a session-persistent ChaCha20 state API to avoid redoing key setup per message and to generate the Poly1305 key block as pure keystream.
  • Adds a one-pass Poly1305 AEAD transcript MAC function and replaces small tail byte loops with memcpy/memset in hot paths.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
port_include/sodium/sodium_esphome.h Declares ESPHome-only fast-path APIs + capability macro for consumers (e.g., noise-c)
patches/05-memcpy-tails.patch Optimizes Poly1305 and ChaCha20 tail handling by replacing byte loops with memcpy and ensuring aligned tail processing
patches/06-noise-session-api.patch Adds session-oriented ChaCha20 entry points and a one-pass Poly1305 AEAD transcript MAC function

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (5)

patches/06-noise-session-api.patch:86

  • This include uses angle brackets for a port-provided header. For consistency with the existing port patch set and to avoid include-order surprises, prefer the quoted include form here too.
 #include <stdint.h>
+#include <sodium/sodium_esphome.h>
 

port_include/sodium/sodium_esphome.h:41

  • The fallback branch defines SODIUM_ESPHOME_NOISE_FAST_PATH even when __has_include is unavailable. That can produce false-positive feature detection (e.g., an unpatched tree compiled with a toolchain lacking __has_include), leading consumers to call these symbols and fail at link time. It’s safer to not advertise the capability unless the marker header is confirmed present (or the build explicitly defines the macro).
#if defined(__has_include)
# if __has_include(<sodium/sodium_esphome_patched.h>)
#  define SODIUM_ESPHOME_NOISE_FAST_PATH 1
# endif
#else
# define SODIUM_ESPHOME_NOISE_FAST_PATH 1
#endif

patches/06-noise-session-api.patch:242

  • crypto_stream_chacha20_ietf_session_block0_xor() accepts nullable c/m in the signature, but the implementation unconditionally passes them to chacha20_encrypt_bytes() when mlen > 0. If a caller accidentally passes mlen>0 with c==NULL or m==NULL, this will crash; adding a small runtime guard makes the API harder to misuse (especially since the header explicitly says it skips normal public-API validation).
+    chacha20_encrypt_bytes(ctx, NULL, block0, 64);
+    /* ctx counter is now 1; the guard skips the call entirely for the
+       block0-only decrypt setup */
+    if (mlen > 0U) {
+        chacha20_encrypt_bytes(ctx, m, c, mlen);
+    }
+

patches/05-memcpy-tails.patch:34

  • poly1305_update() now calls memcpy() even when want==0 (e.g., when bytes==0 but st->leftover is non-zero). The prior loop performed no reads in that case, so this subtly strengthens the requirements on m (some toolchains/UB sanitizers treat memcpy(NULL, ..., 0) as UB). Guarding the memcpy with want!=0 preserves the previous behavior while keeping the fast path for non-zero copies.
     /* handle leftover */
     if (st->leftover) {
         unsigned long long want = (poly1305_block_size - st->leftover);
@@ -24,9 +24,7 @@ poly1305_update(poly1305_state_internal_t *st, const unsigned char *m,
         if (want > bytes) {
             want = bytes;
         }
-        for (i = 0; i < want; i++) {
-            st->buffer[st->leftover + i] = m[i];
-        }
+        memcpy(&st->buffer[st->leftover], m, (size_t) want);
         bytes -= want;
         m += want;

patches/06-noise-session-api.patch:18

  • This patch includes the port header using angle brackets (<sodium/sodium_esphome.h>), but other port patches include port-provided headers using the quoted form (e.g., "sodium/esphome_yield.h"). Using the same include style here reduces the risk of accidentally picking up a different header via include search order and keeps the patch set consistent.

This issue also appears on line 84 of the same file.

+#include <sodium/sodium_esphome.h>
+

Copilot AI review requested due to automatic review settings August 18, 2026 04:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Suppressed comments (3)

port_include/sodium/sodium_esphome.h:71

  • GCC/Clang-only __attribute__((nonnull(...))) in a public header can break non-GNU toolchains (e.g. MSVC) in the plain CMake build. If you keep the annotations, they should be behind a portability macro or compiler guard.
SODIUM_EXPORT
int crypto_stream_chacha20_ietf_session_block0_xor(
        crypto_stream_chacha20_ietf_session_state *st, unsigned char *block0,
        unsigned char *c, const unsigned char *m, unsigned long long mlen,
        uint64_t nonce)
            __attribute__ ((nonnull(1, 2)));

port_include/sodium/sodium_esphome.h:81

  • GCC/Clang-only __attribute__((nonnull)) in a public header can break non-GNU toolchains (e.g. MSVC) in the plain CMake build. If you keep the annotations, they should be behind a portability macro or compiler guard.
SODIUM_EXPORT
int crypto_stream_chacha20_ietf_session_xor(
        crypto_stream_chacha20_ietf_session_state *st, unsigned char *c,
        const unsigned char *m, unsigned long long mlen)
            __attribute__ ((nonnull));

port_include/sodium/sodium_esphome.h:95

  • GCC/Clang-only __attribute__((nonnull(...))) in a public header can break non-GNU toolchains (e.g. MSVC) in the plain CMake build. If you keep the annotations, they should be behind a portability macro or compiler guard.
SODIUM_EXPORT
int crypto_onetimeauth_poly1305_aead_mac(unsigned char *mac,
                                         const unsigned char *ad,
                                         unsigned long long adlen,
                                         const unsigned char *c,
                                         unsigned long long clen,
                                         const unsigned char *key)
            __attribute__ ((nonnull(1, 4, 6)));

Comment thread patches/05-memcpy-tails.patch
Comment thread patches/06-noise-session-api.patch
Comment thread port_include/sodium/sodium_esphome.h
Comment thread port_include/sodium/sodium_esphome.h
Copilot AI review requested due to automatic review settings August 18, 2026 04:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (3)

patches/06-noise-session-api.patch:5

  • This patch file has a free-form preamble before the first diff header. Since pack.sh applies patches using git apply, the file needs to start with a diff header; otherwise the patch won’t apply.
ESPHome noise session API: session persistent ChaCha20 key schedule,
fused block0 plus payload pass with a NULL-input keystream mode, and a
one pass Poly1305 AEAD transcript MAC. Declarations live in
port_include/sodium/sodium_esphome.h; this patch also creates the
marker header that gates the capability macro and forces the tail

patches/05-memcpy-tails.patch:5

  • This patch file has free-form prose before the first diff header. pack.sh applies patches via git apply, which expects patches to start with a diff header; the current leading text will cause git apply to fail parsing the patch.
Portable cleanups for the chacha20 partial block tail and the poly1305
leftover buffering: copy with memcpy instead of byte loops. Standalone;
applies to pristine upstream libsodium as well as this port.

diff --git a/src/libsodium/crypto_onetimeauth/poly1305/donna/poly1305_donna.c b/src/libsodium/crypto_onetimeauth/poly1305/donna/poly1305_donna.c

port_include/sodium/sodium_esphome.h:65

  • The doc comment for crypto_stream_chacha20_ietf_session_block0_xor() implies m/c are always used, but the implementation only dereferences them when mlen > 0. Please document the NULLability/requirements explicitly to prevent accidental NULL dereferences when mlen > 0.
 * Set the IETF nonce (4 zero bytes then the 64-bit nonce, little endian, as
 * the Noise protocol specifies), write the block-0 keystream to block0 and,
 * when mlen > 0, encrypt m into c starting at block counter 1. block0 must
 * be at least 64 bytes: it receives the full ChaCha20 block, of which only
 * the first 32 bytes are the Poly1305 key. Leaves the state's block counter

Copilot AI review requested due to automatic review settings August 18, 2026 04:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

port_include/sodium/sodium_esphome.h:33

  • The comment claims that no supported toolchains lack __has_include, but this repo already uses __has_include in tests/kat.c, so the parenthetical “(none of the supported toolchains)” is misleading/outdated. Consider removing the claim and keeping the generic fallback wording.
 * capability macro. A compiler without __has_include (none of the
 * supported toolchains) also never gets the macro, which fails safe:
 * consumers just take their stock libsodium code path.

@esphbot

esphbot commented Aug 18, 2026

Copy link
Copy Markdown

PR Review — Add fast path patch for ChaCha20-Poly1305 noise operations

Every blocking item from the previous round is genuinely fixed, and I re-verified the crypto end-to-end. Merge-ready; four non-blocking notes.

I applied the full seven-patch stack to the pinned 1.0.21 submodule (all apply cleanly in glob order), built the LIBSODIUM_SRCS subset, and ran both tests/kat.c and an independent adversarial harness. Everything holds: aead_mac reproduces crypto_aead_chacha20poly1305_ietf_encrypt_detached's tag for all 261 x 65 clen/adlen pairs; session_block0_xor matches crypto_stream_chacha20_ietf_xor_ic at ic=1 for mlen 0..300 across every m/c/block0 alignment offset, in-place (c == m), at nonce 0xFFFFFFFFFFFFFFFF, and over 200 consecutive messages on one reused session state; a UBSan trap build is clean; -Wall -Wextra -Wpedantic produces no warnings on either patched file.

Specific things done well in this revision:

  • The marker-header gate is the right fix, and it works in both directions — I compiled a probe against the pristine submodule (MACRO OFF) and the patched tree (MACRO ON), so the generic add_subdirectory target can no longer advertise symbols it doesn't link.
  • Patch 05 is now genuinely standalone: I applied it alone to pristine upstream and compiled both touched files with no patch 03 present. Moving aligned = 1; into patch 06 was the correct split, and patch 06's preamble now declares the 03+05 dependency.
  • session_init zeroes words 12-15, turning the latent uninitialized-nonce read into deterministic state.
  • The header now documents the 64-byte block0 size and the non-NULL-c-on-empty rule.
  • The tail[32] sizing is exact (rem + pad is 0 or 16, never more) and the "leftover stays zero" reasoning is correct at every length I exercised.

Remaining notes, all non-blocking:

  • pack.sh is no longer idempotent — patch 06 is the only patch that creates a file, and reset --hard doesn't remove untracked ones; a second run dies with already exists in working directory. Needs git clean -fd.
  • Nothing asserts the fast path is actually compiled in: kat.c's #else branch passes silently, and whether the untracked marker header survives pio package pack / compote is unverified here.
  • session_xor chaining is block-continuous, not byte-continuous (two 30-byte calls diverge at byte 30) — worth spelling out in the header, along with the nonnull rule for empty payloads.
  • The NULL-input mode converts an m == NULL, mlen > 0 caller bug from a crash into a silently emitted keystream, and nonnull(1, 2) means nothing diagnoses it.

✅ Resolved since last review (4)

Previously-flagged issues verified fixed
  • port_include/sodium/sodium_esphome.h:22 Capability macro is defined even when the patches are not applied, breaking the documented generic CMake path
  • patches/06-noise-session-api.patch:194 session_init leaves the counter and nonce words uninitialized
  • port_include/sodium/sodium_esphome.h:45 block0 output size and the aead_mac non-NULL-on-empty requirement are undocumented
  • patches/05-memcpy-tails.patch:60 Patch 05 is described as upstreamable but depends on patch 03

🟢 Suggestions

1. New marker header makes pack.sh non-idempotent on re-run
patches/06-noise-session-api.patch:259-260

Patch 06 is the first patch in the stack to create a new file (grep -c '^new file mode' patches/*.patch → only 06 matches). pack.sh:11 resets the submodule with git -C libsodium reset --hard HEAD, which restores tracked files but does not remove untracked ones, so src/libsodium/include/sodium/sodium_esphome_patched.h survives the reset.

I reproduced this: applying the full stack twice in a row gives

error: src/libsodium/include/sodium/sodium_esphome_patched.h: already exists in working directory

Because pack.sh runs under set -euxo pipefail, the second ./pack.sh aborts. Until now the script was re-runnable because every patch only touched tracked files, so this is a regression introduced here.

CI and publish.yml are unaffected (fresh checkouts, patches applied once), so this only bites a maintainer iterating locally — and the failure is loud, not silent. One-line fix in pack.sh:

git -C libsodium reset --hard HEAD
git -C libsodium clean -fd
diff --git a/src/libsodium/include/sodium/sodium_esphome_patched.h b/src/libsodium/include/sodium/sodium_esphome_patched.h
new file mode 100644
2. session_xor "continue the keystream" is block-continuous, not byte-continuous
port_include/sodium/sodium_esphome.h:71-79

The comment says "Continue the keystream from the state's current block counter". That is literally accurate, but a reader will most likely take it as byte continuity, and it is not.

chacha20_encrypt_bytes() writes back ctx->input[12] = j12 after PLUSONE, so a call with a length that is not a multiple of 64 leaves the counter on the next block and discards the unused tail of the current one. I confirmed this: block0_xorsession_xor(…, 30)session_xor(…, 30) diverges from crypto_stream_chacha20_ietf_xor_ic(…, 60, ic=1) starting exactly at byte 30. Chunking in 64-byte multiples is continuous.

The single documented usage (one session_xor after a block0-only call) is unaffected, and the sweep 0..300 for that pattern matches the reference exactly. But this header is the whole contract the noise-c side sees, and OTA-over-noise streaming is named in the PR description as the next consumer — chunked writes are precisely where someone would chain calls.

Suggest: "Only one continuation call per nonce is supported; chaining several calls is keystream-continuous only when every mlen is a multiple of 64."

Separately, __attribute__((nonnull)) here covers m as well, so session_xor(st, c, NULL, 0) — the natural empty-Noise-payload call — is a contract violation even though the implementation returns immediately on !bytes. aead_mac got an explicit "c must be non-NULL even when clen is 0" note; the same sentence (or nonnull(1, 2)) is missing here.

/*
 * Continue the keystream from the state's current block counter (e.g. the
 * payload pass after a block0-only call above).
 */
3. NULL-input mode turns a caller bug from a crash into silent keystream disclosure
patches/06-noise-session-api.patch:96

The m != NULL guards change chacha20_encrypt_bytes() from "segfault on a NULL plaintext pointer" to "emit raw keystream". The mode is correct and I verified it produces exactly the right keystream, including sub-64-byte tails and the forced-aligned tail path (UBSan trap build clean over the whole sweep).

The concern is the failure mode it opens up. crypto_stream_chacha20_ietf_session_block0_xor is declared nonnull(1, 2)m is explicitly allowed to be NULL, so neither the compiler nor a runtime check will catch a caller that passes m == NULL with mlen > 0. The result is a well-formed-looking ciphertext that is the bare keystream, and a matching Poly1305 tag over it. Nothing crashes, nothing returns an error, and the frame goes out on the wire — a keystream disclosure rather than a fault.

Before this change that same bug was an immediate NULL dereference. Cheap to restore, once per message, off the inner loop:

if (mlen > 0U) {
    if (m == NULL) {
        return -1;
    }
    chacha20_encrypt_bytes(ctx, m, c, mlen);
}

At minimum, state in the header that m may be NULL only when mlen == 0.

+            if (m != NULL) {

Checklist

  • Patches apply cleanly in glob order against the pinned submodule
  • Patch 05 applies and compiles standalone against pristine upstream
  • Fast path byte-identical to the reference AEAD (RFC 8439 + differential sweeps)
  • No buffer overruns or UB in the new tail/MAC buffers (UBSan trap build clean)
  • State fully initialized before use
  • Feature detection cannot succeed without the implementation
  • Public API contract fully documented (buffer sizes, NULL rules, continuation semantics) — suggestion #2, suggestion #3
  • Packaging and repo tooling unaffected by the new patch — suggestion #1
  • No new compiler warnings (-Wall -Wextra -Wpedantic)
  • Diff matches the PR description, no scope creep

Automated review by Kōan (Claude) HEAD=8ba5f08 13 min 44s

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip

No blocking issues found — ready to merge.

Copilot AI review requested due to automatic review settings August 18, 2026 04:43
@bdraco

bdraco commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

All three suggestions adopted: pack.sh now runs git clean -fd after the reset, so the marker header from patch 06 no longer breaks a second run (verified by applying the stack twice locally); session_block0_xor returns -1 for a NULL m with nonzero mlen instead of emitting bare keystream, and the header documents that m and c may be NULL only when mlen is 0; the session_xor comment now states that only one continuation call per nonce is supported, that chaining is keystream continuous only in 64 byte multiples, and that c and m must be non NULL even for empty payloads.

@bdraco
bdraco merged commit fdac2b5 into main Aug 18, 2026
7 checks passed
@bdraco
bdraco deleted the noise-fast-path branch August 18, 2026 04:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

patches/06-noise-session-api.patch:244

  • crypto_stream_chacha20_ietf_session_block0_xor() documents that c may be NULL only when mlen == 0, but the implementation only guards against m == NULL. If a caller passes c == NULL with mlen > 0, this will call chacha20_encrypt_bytes() with a NULL output pointer and likely crash.
+    if (mlen > 0U) {
+        if (m == NULL) {
+            return -1; /* refuse to emit bare keystream on a caller bug */
+        }
+        chacha20_encrypt_bytes(ctx, m, c, mlen);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants