From 05c0ed8bb29e9002c81c86b61a1ff34d8ca9ede0 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 13:53:00 -0700 Subject: [PATCH] Fit STM32 Companions with equivalent compact field arithmetic --- .github/workflows/run-unit-tests.yml | 1 + lib/ed25519/fe.c | 17 ++ lib/ed25519/fe_compact.h | 89 ++++++++++ test/test_ed25519_compact.py | 17 ++ test/test_ed25519_compact_field.py | 241 +++++++++++++++++++++++++++ test/test_stm32_companion_profile.py | 4 + variants/rak3x72/platformio.ini | 1 + variants/wio-e5-mini/platformio.ini | 1 + 8 files changed, 371 insertions(+) create mode 100644 lib/ed25519/fe_compact.h create mode 100644 test/test_ed25519_compact_field.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 343f7e41d..9d3dd2fa1 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -114,6 +114,7 @@ jobs: run: | python3 -B test/test_stm32_companion_profile.py -v python3 -B test/test_ed25519_compact_scalar.py -v + python3 -B test/test_ed25519_compact_field.py -v python3 -B test/test_ed25519_compact.py -v - name: Compile ordinary STM32 USB Companion with pinned core and RadioLib diff --git a/lib/ed25519/fe.c b/lib/ed25519/fe.c index 2105eb7b2..909ce0710 100644 --- a/lib/ed25519/fe.c +++ b/lib/ed25519/fe.c @@ -1,6 +1,10 @@ #include "fixedint.h" #include "fe.h" +#if defined(ED25519_COMPACT_FIELD) && ED25519_COMPACT_FIELD +#include "fe_compact.h" +#endif + /* helper functions @@ -78,6 +82,7 @@ void fe_1(fe h) { |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_add(fe h, const fe f, const fe g) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -294,6 +299,8 @@ void fe_copy(fe h, const fe f) { Ignores top bit of h. */ +#endif + void fe_frombytes(fe h, const unsigned char *s) { int64_t h0 = load_4(s); int64_t h1 = load_3(s + 4) << 6; @@ -553,6 +560,7 @@ int fe_isnonzero(const fe f) { With tighter constraints on inputs can squeeze carries into int32. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_mul(fe h, const fe f, const fe g) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -763,6 +771,7 @@ void fe_mul(fe h, const fe f, const fe g) { h[8] = (int32_t) h8; h[9] = (int32_t) h9; } +#endif /* @@ -843,6 +852,7 @@ Postconditions: |h| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_neg(fe h, const fe f) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -878,6 +888,8 @@ void fe_neg(fe h, const fe f) { } +#endif + void fe_pow22523(fe out, const fe z) { fe t0; fe t1; @@ -979,6 +991,7 @@ Postconditions: See fe_mul.c for discussion of implementation strategy. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_sq(fe h, const fe f) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -1298,6 +1311,7 @@ void fe_sq2(fe h, const fe f) { h[8] = (int32_t) h8; h[9] = (int32_t) h9; } +#endif /* @@ -1312,6 +1326,7 @@ Postconditions: |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. */ +#if !defined(ED25519_COMPACT_FIELD) || !ED25519_COMPACT_FIELD void fe_sub(fe h, const fe f, const fe g) { int32_t f0 = f[0]; int32_t f1 = f[1]; @@ -1383,6 +1398,8 @@ Proof: so floor(2^(-255)(h + 19 2^(-25) h9 + 2^(-1))) = q. */ +#endif + void fe_tobytes(unsigned char *s, const fe h) { int32_t h0 = h[0]; int32_t h1 = h[1]; diff --git a/lib/ed25519/fe_compact.h b/lib/ed25519/fe_compact.h new file mode 100644 index 000000000..a00c005fc --- /dev/null +++ b/lib/ed25519/fe_compact.h @@ -0,0 +1,89 @@ +/* + * Fixed-loop form of the ref10 field products in fe.c. Only the headless + * STM32 Companion size recipes opt in; other builds keep the unrolled code. + * The radix alternates 26 and 25 bits. Odd-by-odd products therefore have + * weight 2, and terms beyond bit 254 fold back with weight 19, exactly as in + * ref10. Product accumulation and the rounded carry order are unchanged. + * + * All loops, branches and array addresses depend only on public limb indices + * or the public operation (multiply, square, double-square), not input values. + * Under ref10's 1.65*2^26 / 1.65*2^25 input bounds, even doubled sums fit in + * signed 64 bits. Outputs are written only after reading every input, so h + * may alias f or g. No additional persistent RAM or heap is needed. + */ + +void fe_add(fe h, const fe f, const fe g) { + int i; + for (i = 0; i < 10; ++i) h[i] = f[i] + g[i]; +} + +void fe_sub(fe h, const fe f, const fe g) { + int i; + for (i = 0; i < 10; ++i) h[i] = f[i] - g[i]; +} + +void fe_neg(fe h, const fe f) { + int i; + for (i = 0; i < 10; ++i) h[i] = -f[i]; +} + +void fe_copy(fe h, const fe f) { + int i; + for (i = 0; i < 10; ++i) h[i] = f[i]; +} + +void fe_cmov(fe f, const fe g, unsigned int b) { + const int32_t mask = -(int32_t)b; + int i; + for (i = 0; i < 10; ++i) f[i] ^= mask & (f[i] ^ g[i]); +} + +void fe_cswap(fe f, fe g, unsigned int b) { + const int32_t mask = -(int32_t)b; + int i; + for (i = 0; i < 10; ++i) { + const int32_t swap = mask & (f[i] ^ g[i]); + f[i] ^= swap; + g[i] ^= swap; + } +} + +static void fe_compact_product(fe h, const fe f, const fe g, int doubled) { + static const unsigned char carry_order[] = {0, 4, 1, 5, 2, 6, 3, 7, 4, 8, 9, 0}; + int64_t limbs[10] = {0}; + int i, j; + for (i = 0; i < 10; ++i) { + for (j = 0; j < 10; ++j) { + const int sum = i + j; + const int index = sum < 10 ? sum : sum - 10; + const int weight = (sum < 10 ? 1 : 19) * (1 + (i & j & 1)); + limbs[index] += (int64_t)f[i] * g[j] * weight; + } + } + if (doubled) { + for (i = 0; i < 10; ++i) limbs[i] += limbs[i]; + } + for (i = 0; i < (int)sizeof(carry_order); ++i) { + const int index = carry_order[i]; + const int bits = 26 - (index & 1); + const int64_t radix = (int64_t)1 << bits; + const int64_t carry = (limbs[index] + radix / 2) >> bits; + if (index == 9) limbs[0] += carry * 19; + else limbs[index + 1] += carry; + /* A carry may be negative; do not left-shift that signed value. */ + limbs[index] -= carry * radix; + } + for (i = 0; i < 10; ++i) h[i] = (int32_t)limbs[i]; +} + +void fe_mul(fe h, const fe f, const fe g) { + fe_compact_product(h, f, g, 0); +} + +void fe_sq(fe h, const fe f) { + fe_compact_product(h, f, f, 0); +} + +void fe_sq2(fe h, const fe f) { + fe_compact_product(h, f, f, 1); +} diff --git a/test/test_ed25519_compact.py b/test/test_ed25519_compact.py index 9950368a0..cbce644f8 100644 --- a/test/test_ed25519_compact.py +++ b/test/test_ed25519_compact.py @@ -52,6 +52,10 @@ void public_from_scalar(unsigned char *out, const unsigned char *scalar) { ("small_sha512", ["-DED25519_SMALL_BASE=1", "-DED25519_COMPACT_SHA512=1"]), ("small_all", ["-DED25519_SMALL_BASE=1", "-DED25519_COMPACT_SHA512=1", "-DED25519_COMPACT_SCALAR=1"]), + ("small_field", ["-DED25519_SMALL_BASE=1", "-DED25519_COMPACT_SHA512=1", + "-DED25519_COMPACT_SCALAR=1", "-DED25519_COMPACT_FIELD=1"]), + ("compact_field", ["-DED25519_COMPACT_BASE=1", "-DED25519_COMPACT_SHA512=1", + "-DED25519_COMPACT_SCALAR=1", "-DED25519_COMPACT_FIELD=1"]), ): path = Path(directory) / (label + ".so") subprocess.run(["cc", "-std=c99", "-O2", "-Wall", "-Wextra", "-Werror", @@ -65,6 +69,9 @@ void public_from_scalar(unsigned char *out, const unsigned char *scalar) { library.ed25519_sign.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p] library.ed25519_key_exchange.argtypes = [ctypes.c_void_p] * 3 + library.ed25519_verify.argtypes = [ctypes.c_void_p, ctypes.c_void_p, + ctypes.c_size_t, ctypes.c_void_p] + library.ed25519_verify.restype = ctypes.c_int libraries.append(library) rng = random.Random(0xED960) scalars = [0, (1 << 255) - 1] @@ -93,6 +100,12 @@ void public_from_scalar(unsigned char *out, const unsigned char *scalar) { library.ed25519_create_keypair(peer, peer_prv, peer_seed) library.ed25519_sign(sig, message, len(message), pub, prv) library.ed25519_key_exchange(secret, peer, prv) + self.assertEqual(library.ed25519_verify(sig, message, len(message), pub), 1) + damaged = bytearray(sig.raw) + damaged[iteration % len(damaged)] ^= 1 << (iteration % 8) + self.assertEqual(library.ed25519_verify(bytes(damaged), message, len(message), pub), 0) + wrong_message = bytes([message[0] ^ 1]) + message[1:] if message else b"x" + self.assertEqual(library.ed25519_verify(sig, wrong_message, len(wrong_message), pub), 0) outputs.append((pub.raw, prv.raw, sig.raw, secret.raw)) for output in outputs[1:]: self.assertEqual(outputs[0], output) @@ -187,6 +200,10 @@ void public_from_scalar(unsigned char *out, const unsigned char *scalar) { ("compact_all", ["-DED25519_COMPACT_BASE=1", "-DED25519_COMPACT_SHA512=1", "-DED25519_COMPACT_SCALAR=1"]), + ("compact_field", ["-DED25519_COMPACT_BASE=1", + "-DED25519_COMPACT_SHA512=1", + "-DED25519_COMPACT_SCALAR=1", + "-DED25519_COMPACT_FIELD=1"]), ): executable = Path(temp_dir) / label subprocess.run( diff --git a/test/test_ed25519_compact_field.py b/test/test_ed25519_compact_field.py new file mode 100644 index 000000000..9d157555b --- /dev/null +++ b/test/test_ed25519_compact_field.py @@ -0,0 +1,241 @@ +"""Prove compact ref10 products against unrolled code and integer arithmetic.""" + +import ctypes +from pathlib import Path +import random +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ED25519 = ROOT / "lib/ed25519" +SHIFTS = (0, 26, 51, 77, 102, 128, 153, 179, 204, 230) +BOUNDS = tuple(165 * (1 << (26 - (i & 1))) // 100 for i in range(10)) +PRIME = 2**255 - 19 +Field = ctypes.c_int32 * 10 + + +def integer(limbs): + return sum(value << shift for value, shift in zip(limbs, SHIFTS)) % PRIME + + +def vectors(seed, count): + values = [[0] * 10, [1] + [0] * 9, list(BOUNDS), [-x for x in BOUNDS], + [x if i & 1 else -x for i, x in enumerate(BOUNDS)]] + for index, bound in enumerate(BOUNDS): + for value in (-bound, -bound + 1, -1, 1, bound - 1, bound): + item = [0] * 10 + item[index] = value + values.append(item) + for bit in range(26 - (index & 1)): + for delta in (-1, 0, 1): + item = [0] * 10 + item[index] = (1 << bit) + delta + values.append(item) + rng = random.Random(seed) + values += [[rng.randint(-bound, bound) for bound in BOUNDS] + for _ in range(count)] + return values + + +class Ed25519CompactFieldTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.directory = tempfile.TemporaryDirectory() + cls.libraries = [] + for compact in (0, 1): + path = Path(cls.directory.name) / ("field-%d.so" % compact) + subprocess.run(["cc", "-std=c99", "-O2", "-Wall", "-Wextra", "-Werror", + "-shared", "-fPIC", "-DED25519_COMPACT_FIELD=%d" % compact, + str(ED25519 / "fe.c"), "-o", str(path)], check=True) + library = ctypes.CDLL(str(path)) + for name, arguments in (("fe_mul", 3), ("fe_sq", 2), ("fe_sq2", 2)): + function = getattr(library, name) + function.argtypes = [ctypes.POINTER(ctypes.c_int32)] * arguments + function.restype = None + for name, arguments in (("fe_add", 3), ("fe_sub", 3), + ("fe_copy", 2), ("fe_neg", 2)): + getattr(library, name).argtypes = [ctypes.POINTER(ctypes.c_int32)] * arguments + getattr(library, name).restype = None + for name in ("fe_cmov", "fe_cswap"): + getattr(library, name).argtypes = [ctypes.POINTER(ctypes.c_int32)] * 2 + [ctypes.c_uint] + getattr(library, name).restype = None + cls.libraries.append(library) + + @classmethod + def tearDownClass(cls): + cls.directory.cleanup() + + def check_bounds(self, result): + for index, value in enumerate(result): + self.assertLessEqual(abs(value), 101 * (1 << (25 - (index & 1))) // 100) + + def test_multiply_matches_original_integer_oracle_and_aliases(self): + values = vectors(0xEDFE, 8000) + rng = random.Random(0x26A25) + for index, first in enumerate(values): + second = values[rng.randrange(len(values))] + expected = integer(first) * integer(second) % PRIME + reference = Field() + self.libraries[0].fe_mul(reference, Field(*first), Field(*second)) + for alias in range(3): + a, b = Field(*first), Field(*second) + output = a if alias == 0 else b if alias == 1 else Field() + self.libraries[1].fe_mul(output, a, b) + self.assertEqual(list(output), list(reference), (index, alias)) + self.assertEqual(integer(output), expected, (index, alias)) + self.check_bounds(output) + + def test_square_and_double_square_match_original_oracle_and_aliases(self): + for index, first in enumerate(vectors(0x25519, 8000)): + for name, scale in (("fe_sq", 1), ("fe_sq2", 2)): + expected = integer(first)**2 * scale % PRIME + reference = Field() + getattr(self.libraries[0], name)(reference, Field(*first)) + for alias in (False, True): + data = Field(*first) + output = data if alias else Field() + getattr(self.libraries[1], name)(output, data) + self.assertEqual(list(output), list(reference), (index, name, alias)) + self.assertEqual(integer(output), expected, (index, name, alias)) + self.check_bounds(output) + data = Field(*first) + self.libraries[1].fe_mul(data, data, data) + self.assertEqual(integer(data), integer(first)**2 % PRIME) + + def test_worst_case_products_and_carries_fit_signed_64_bits(self): + # Absolute products bound every mixed-sign partial sum as well as the + # final sum. Include the doubled-square operation and carry growth. + limbs = [0] * 10 + for i in range(10): + for j in range(10): + weight = (1 if i + j < 10 else 19) * (1 + (i & j & 1)) + limbs[(i + j) % 10] += BOUNDS[i] * BOUNDS[j] * weight * 2 + self.assertLess(max(limbs), 2**62) + for index in (0, 4, 1, 5, 2, 6, 3, 7, 4, 8, 9, 0): + radix = 1 << (26 - (index & 1)) + self.assertLess(limbs[index] + radix // 2, 2**63) + carry_bound = (limbs[index] + radix // 2) // radix + 1 + limbs[0 if index == 9 else index + 1] += carry_bound * (19 if index == 9 else 1) + limbs[index] = radix // 2 + self.assertLess(max(limbs), 2**63) + + def test_limb_helpers_match_original_and_aliases(self): + values = vectors(0xC0A5, 4000) + for index, first in enumerate(values): + second = values[-1 - index] + for name in ("fe_add", "fe_sub", "fe_copy", "fe_neg"): + binary = name in ("fe_add", "fe_sub") + reference = Field() + inputs = [Field(*first)] + ([Field(*second)] if binary else []) + getattr(self.libraries[0], name)(reference, *inputs) + for alias in range(3 if binary else 2): + inputs = [Field(*first)] + ([Field(*second)] if binary else []) + output = inputs[alias] if alias < len(inputs) else Field() + getattr(self.libraries[1], name)(output, *inputs) + self.assertEqual(list(output), list(reference), (index, name, alias)) + for name in ("fe_cmov", "fe_cswap"): + for select in (0, 1): + expected_a, expected_b = Field(*first), Field(*second) + getattr(self.libraries[0], name)(expected_a, expected_b, select) + a, b = Field(*first), Field(*second) + getattr(self.libraries[1], name)(a, b, select) + self.assertEqual((list(a), list(b)), (list(expected_a), list(expected_b))) + a = Field(*first) + getattr(self.libraries[1], name)(a, a, select) + self.assertEqual(list(a), first) + + def test_compact_products_under_address_and_undefined_sanitizers(self): + values = vectors(0xC0FE, 128) + # Keep every edge/carry-boundary vector and a bounded random selection. + rows = [] + for index, first in enumerate(values): + second = values[-1 - index] + outputs = [] + for name in ("fe_mul", "fe_sq", "fe_sq2"): + output = Field() + arguments = (output, Field(*first), Field(*second)) if name == "fe_mul" else (output, Field(*first)) + getattr(self.libraries[0], name)(*arguments) + outputs.append(list(output)) + rows.append("{" + ",".join("{" + ",".join(map(str, row)) + "}" + for row in (first, second, *outputs)) + "}") + source = r''' +#include +#include +#include "fe.h" +struct Vector { fe a, b, product, square, twice; }; +static const struct Vector vectors[] = { @VECTORS@ }; +int main(void) { + unsigned int i, alias; + for (i = 0; i < sizeof(vectors) / sizeof(vectors[0]); ++i) { + for (alias = 0; alias < 3; ++alias) { + fe a, b, separate; + int32_t *output; + memcpy(a, vectors[i].a, sizeof(a)); + memcpy(b, vectors[i].b, sizeof(b)); + output = alias == 0 ? a : alias == 1 ? b : separate; + fe_mul(output, a, b); + assert(memcmp(output, vectors[i].product, sizeof(fe)) == 0); + memcpy(a, vectors[i].a, sizeof(a)); + output = alias == 0 ? a : separate; + fe_sq(output, a); + assert(memcmp(output, vectors[i].square, sizeof(fe)) == 0); + memcpy(a, vectors[i].a, sizeof(a)); + fe_sq2(output, a); + assert(memcmp(output, vectors[i].twice, sizeof(fe)) == 0); + memcpy(a, vectors[i].a, sizeof(a)); + memcpy(b, vectors[i].b, sizeof(b)); + fe_add(separate, a, b); + for (unsigned int limb = 0; limb < 10; ++limb) + assert(separate[limb] == a[limb] + b[limb]); + fe_sub(separate, a, b); + for (unsigned int limb = 0; limb < 10; ++limb) + assert(separate[limb] == a[limb] - b[limb]); + fe_neg(separate, a); + for (unsigned int limb = 0; limb < 10; ++limb) + assert(separate[limb] == -a[limb]); + fe_copy(separate, a); + assert(memcmp(separate, a, sizeof(fe)) == 0); + fe_cmov(a, b, 0); + assert(memcmp(a, vectors[i].a, sizeof(fe)) == 0); + fe_cmov(a, b, 1); + assert(memcmp(a, b, sizeof(fe)) == 0); + memcpy(a, vectors[i].a, sizeof(a)); + fe_cswap(a, b, 0); + assert(memcmp(a, vectors[i].a, sizeof(fe)) == 0); + fe_cswap(a, b, 1); + assert(memcmp(a, vectors[i].b, sizeof(fe)) == 0); + assert(memcmp(b, vectors[i].a, sizeof(fe)) == 0); + } + } + return 0; +} +'''.replace("@VECTORS@", ",\n".join(rows)) + with tempfile.TemporaryDirectory() as directory: + harness = Path(directory) / "fields.c" + harness.write_text(source) + executable = Path(directory) / "fields" + subprocess.run(["cc", "-std=c99", "-O1", "-g", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-no-pie", + "-DED25519_COMPACT_FIELD=1", "-I", str(ED25519), + str(harness), str(ED25519 / "fe.c"), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True) + + def test_only_stm32_companion_recipes_opt_in(self): + profiles = [] + for ini in (ROOT / "variants").glob("*/platformio.ini"): + section = "" + for line in ini.read_text().splitlines(): + if line.startswith("["): + section = line + if "ED25519_COMPACT_FIELD" in line: + profiles.append((ini.parent.name, section)) + self.assertEqual(sorted(profiles), [ + ("rak3x72", "[env:RAK_3x72_companion_radio_usb]"), + ("wio-e5-mini", "[env:wio-e5-mini_companion_radio_usb]"), + ]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_stm32_companion_profile.py b/test/test_stm32_companion_profile.py index c46ea7c3d..ec160a679 100644 --- a/test/test_stm32_companion_profile.py +++ b/test/test_stm32_companion_profile.py @@ -55,6 +55,10 @@ class STM32CompanionProfileTest(unittest.TestCase): self.assertIn("-Oz", self.companion["build_flags"]) self.assertEqual(self.companion["build_unflags"].split(), ["-Os"]) + def test_size_profile_uses_equivalent_fixed_loop_field_products(self): + self.assertRegex(self.companion["build_flags"], + r"-D\s+ED25519_COMPACT_FIELD=1\b") + class WioSTM32CompanionProfileTest(STM32CompanionProfileTest): variant = "wio-e5-mini" diff --git a/variants/rak3x72/platformio.ini b/variants/rak3x72/platformio.ini index ddd87e295..2b47492c9 100644 --- a/variants/rak3x72/platformio.ini +++ b/variants/rak3x72/platformio.ini @@ -72,6 +72,7 @@ build_flags = ${rak3x72.build_flags} -D HAL_TIM_MODULE_ONLY -D ED25519_COMPACT_SHA512=1 ; same hash rounds, compact loop preserves the filesystem boundary -D ED25519_COMPACT_SCALAR=1 ; same ref10 arithmetic with fixed loops instead of unrolled code + -D ED25519_COMPACT_FIELD=1 ; share fixed-loop field products without cutting features or storage -Oz -flto -fno-reorder-blocks diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index eab2af235..01ade17b4 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -85,6 +85,7 @@ build_flags = ${lora_e5_mini.build_flags} -D HAL_TIM_MODULE_ONLY -D ED25519_COMPACT_SHA512=1 ; same hash rounds, compact loop preserves the filesystem boundary -D ED25519_COMPACT_SCALAR=1 ; same ref10 arithmetic with fixed loops instead of unrolled code + -D ED25519_COMPACT_FIELD=1 ; share fixed-loop field products without cutting features or storage -Oz -flto -fno-reorder-blocks