From 55385ca6bdf9ef1de97854f395158737c83ec248 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 22 Sep 2026 12:59:16 -0500 Subject: [PATCH 1/2] correction: Bound alignment cache Limit attacker-keyed syndrome alignment state to eight recent entries while retaining reuse for normal correction work. Add churn and cache-hit regression coverage.\n\nFixes #21. --- src/codex32/correction.py | 8 ++++++-- tests/test_alignment.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 4a1a5616..5f4e20fa 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -23,7 +23,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, replace -from functools import cache +from functools import cache, lru_cache from math import comb from time import monotonic from typing import Literal @@ -250,6 +250,10 @@ class _Spec: 1023, ) +# Keep all six standard ms target lengths hot without letting arbitrary HRPs +# create process-lifetime state. +_ALIGNMENT_CACHE_SIZE = 8 + def _spec_for_checksum(checksum: _Checksum) -> _Spec: if checksum is _CODEX32: @@ -276,7 +280,7 @@ def _pack_syndromes(values: tuple[int, ...]) -> int: return sum(value << (10 * index) for index, value in enumerate(values)) -@cache +@lru_cache(maxsize=_ALIGNMENT_CACHE_SIZE) def _syndrome_alignment(spec: _Spec, hrp: str, length: int) -> tuple[int, tuple[tuple[int, ...], ...]]: base = _pack_syndromes(_syndromes(spec, _residue(spec, hrp, [0] * length), target=True)) powers = _poly_powers(spec.generator, length) diff --git a/tests/test_alignment.py b/tests/test_alignment.py index 8543fa2b..e21c06d0 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -6,6 +6,7 @@ from codex32._alignment import _IncrementalSyndromes, _View from codex32.correction import ( + _ALIGNMENT_CACHE_SIZE, _LONG_SPEC, _SHORT_SPEC, _pack_syndromes, @@ -15,6 +16,29 @@ ) +def test_syndrome_alignment_cache_is_bounded_for_untrusted_hrps(): + _syndrome_alignment.cache_clear() + + for index in range(_ALIGNMENT_CACHE_SIZE + 5): + _syndrome_alignment(_SHORT_SPEC, f"attacker{index}", 45) + + info = _syndrome_alignment.cache_info() + assert info.maxsize == _ALIGNMENT_CACHE_SIZE + assert info.currsize == _ALIGNMENT_CACHE_SIZE + + +def test_syndrome_alignment_cache_still_reuses_recent_entries(): + _syndrome_alignment.cache_clear() + first = _syndrome_alignment(_SHORT_SPEC, "ms", 45) + before = _syndrome_alignment.cache_info() + + second = _syndrome_alignment(_SHORT_SPEC, "ms", 45) + + after = _syndrome_alignment.cache_info() + assert first is second + assert after.hits == before.hits + 1 + + @pytest.mark.parametrize( ("hrp", "length", "spec"), ( From fceaeeccf31ae67a5590aab84b175bd643dbd534 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Tue, 22 Sep 2026 13:28:10 -0500 Subject: [PATCH 2/2] correction: retain target alignments Size the bounded alignment LRU for the full 11-length generic unknown-target window so repeated search phases reuse every alignment without reintroducing unbounded attacker-controlled state. Add a regression proving the full window remains hot.\n\nSecurity: preserves the resource bound from #21 while avoiding cache thrash that can consume the fixed correction deadline.\n\nValidation: test_alignment.py 26 passed; full pytest 863 passed; optimized pytest 863 passed; Ruff, format, mypy, and git diff --check passed. --- src/codex32/correction.py | 6 +++--- tests/test_alignment.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 5f4e20fa..c30ce7ca 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -250,9 +250,9 @@ class _Spec: 1023, ) -# Keep all six standard ms target lengths hot without letting arbitrary HRPs -# create process-lifetime state. -_ALIGNMENT_CACHE_SIZE = 8 +# Keep every target length from one generic unknown-length search hot without +# letting arbitrary HRPs create process-lifetime state. +_ALIGNMENT_CACHE_SIZE = 11 def _spec_for_checksum(checksum: _Checksum) -> _Spec: diff --git a/tests/test_alignment.py b/tests/test_alignment.py index e21c06d0..b67b8ac6 100644 --- a/tests/test_alignment.py +++ b/tests/test_alignment.py @@ -27,6 +27,22 @@ def test_syndrome_alignment_cache_is_bounded_for_untrusted_hrps(): assert info.currsize == _ALIGNMENT_CACHE_SIZE +def test_syndrome_alignment_cache_fits_one_unknown_length_search(): + observed = 50 + targets = sorted({observed + delta for delta in (*range(-4, 5), -8, 8)}) + _syndrome_alignment.cache_clear() + + first = [_syndrome_alignment(_SHORT_SPEC, "generic", target) for target in targets] + before = _syndrome_alignment.cache_info() + second = [_syndrome_alignment(_SHORT_SPEC, "generic", target) for target in targets] + after = _syndrome_alignment.cache_info() + + assert len(targets) == _ALIGNMENT_CACHE_SIZE + assert all(left is right for left, right in zip(first, second, strict=True)) + assert after.misses == before.misses + assert after.hits == before.hits + len(targets) + + def test_syndrome_alignment_cache_still_reuses_recent_entries(): _syndrome_alignment.cache_clear() first = _syndrome_alignment(_SHORT_SPEC, "ms", 45)