Repository navigation
fix(ed25519): reject a non-canonical S, and share the contract with eBoot - #122
Conversation
|
Follow-up verification on the one thing the corpus cannot settle by itself: whether The three RFC 8032 vectors prove it accepts something, but their S values are arbitrary and say nothing about behaviour near the boundary. An off-by-one that refused a valid eos is verify-only — That largest sample sits within 1.4e-4 of L, which is the region an off-by-one would break, so this is boundary coverage and not just bulk. No over-rejection. Stating the limit honestly rather than implying more: through the public API a signature with Not committed — it needs |
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#122 "fix(ed25519): reject a non-canonical S, and share the contract with eBoot"
head: e1152b0 author: Kartikey1306 ci: pass
Verdict: The malleability fix is correct — I checked the L constant, the comparison
and the placement, and all three are right. The problem is the other half of the PR: the
shared-contract mechanism is supposed to make divergence between eos and eBoot visible,
and nothing in it fails when the two diverge. The digest is printed to stdout and never
compared to anything, the vector file is generated locally by each repo's own copy of the
generator, and a stale copy in the source tree would silently win over the generated one.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | High | tests/test_ed25519_contract.c:33 |
The digest is printed, not asserted — the contract mechanism cannot fail. The file's own docstring states the guarantee: "A change to one copy that does not reach the other shows up as two different digests." Nothing compares them. EOS_ED25519_CONTRACT_DIGEST goes to printf and is never checked, and because the header is generated by this repo's own tools/gen_ed25519_contract_vectors.py at build time, the digest is by construction whatever this repo's generator produces. Edit the generator here and not in eBoot: the corpus changes, the digest changes, test_ed25519_contract still exits 0, ctest is green, and nothing in either repo notices. The value 1059febedae2b3e3dfaa1ef5b419fb37ed7f0d53b377a3250b53b95a546282a2 appears in the PR body and in no committed file. .ai/reviewer.md: a verification whose result is discarded is a finding. |
Pin it in the committed test source — not in the generated header, which would make it self-fulfilling again: #define EOS_ED25519_CONTRACT_EXPECTED "1059feb…" in test_ed25519_contract.c, then if (strcmp(EOS_ED25519_CONTRACT_DIGEST, EOS_ED25519_CONTRACT_EXPECTED) != 0) { print both; return 1; }. Now changing the generator fails the build until someone edits a constant, and that edit is a one-line diff a reviewer can hold against eBoot's. Four lines, and it is the difference between a mechanism and a convention. |
| 2 | Medium | tests/CMakeLists.txt:113-117, tests/test_ed25519_contract.c:26 |
A stale source-tree copy of the vectors silently wins. target_include_directories lists ${CMAKE_CURRENT_SOURCE_DIR} before ${CMAKE_CURRENT_BINARY_DIR} (:116-117), and the test includes the relative path "vectors/ed25519_contract_vectors.h". If tests/vectors/ed25519_contract_vectors.h ever exists in the working tree it shadows the generated file. The PR body actively creates that situation — it tells the reader to re-derive with python3 tools/gen_ed25519_contract_vectors.py > tests/vectors/ed25519_contract_vectors.h, writing to exactly that path. tests/vectors/ does not exist in the tree and is not in .gitignore, so the file would sit there untracked and invisible to git status --short filters, and with finding 1 unfixed there is no digest check to catch it. |
Put ${CMAKE_CURRENT_BINARY_DIR} first in target_include_directories, add tests/vectors/ to .gitignore, and change the body's re-derivation command to -o into a scratch path rather than into the include search path. Any one of the three closes it; all three are cheap. |
| 3 | Medium | tools/gen_ed25519_contract_vectors.py (corpus composition) |
The corpus omits the divergence class most likely to differ between the two verifiers. The 76 vectors are 64 low-order public keys, 3 RFC 8032 §7.1 positives, 6 single-bit flips and 3 S + L. RFC 8032 §5.1.7 has a second decoding condition beyond the S range, and it is not represented: a non-canonically encoded point, y ≥ p. For R this side is safe by construction — ed25519_verify never decodes R, it re-encodes the computed point and compares bytes (services/crypto/src/ed25519_verify.c:143-145), so a non-canonical R cannot match. For the public key it is not: ge_frombytes_negate_vartime (:126) is ref10, which accepts y in [p, 2^255) for several encodings, and ed25519_key_has_prime_order then operates on a successfully decoded point. Whether eBoot's verifier does the same is exactly what nobody can currently tell — which is the problem this corpus was built to solve. |
Add two families to the generator: a public key re-encoded with y ≥ p (the standard y + p alias of a valid key), and S = L and S = L - 1. Give each an expect_accept and a why, and let the two repos disagree loudly if they disagree. This is the highest-value addition available, because it is the case the author's own argument predicts will diverge. |
| 4 | Medium | services/crypto/src/ed25519_verify.c:131-145 (sc_is_canonical) |
The accept side of the boundary has no committed regression test. The 2026-09-01 comment identifies this correctly — an off-by-one refusing S = L - 1 would satisfy every vector in the corpus — and answers it with 200 externally generated signatures that are deliberately not committed because they need cryptography. That leaves the check the author himself flagged as the risky one covered by a one-off nobody will re-run, and the comment concedes S == L is covered "by inspection". |
The comparison is a pure function of 32 bytes; it does not need signatures to test. Drop static from sc_is_canonical behind a test-only declaration (or move it to a small internal header shared with the test), and assert directly: 0 → 1, L-1 → 1, L → 0, L+1 → 0, 2^253-1 → 0, all-0xFF → 0. That is a discriminating test for every boundary the corpus cannot reach, it needs no Python and no cryptography, and it replaces "by inspection" with a check that runs on every build. |
| 5 | Low | tests/test_ed25519_contract.c:65-70 |
The all-refusing-verifier guard is the right idea but asserts positives == 0 rather than the expected count. A corpus that lost two of its three RFC 8032 positives still passes, and the comment above it names three. |
if (positives != 3), or fold it into finding 1 — a pinned digest already fixes the corpus exactly and makes this guard a second line of defence rather than the only one. |
| 6 | Low | tests/CMakeLists.txt:106 |
find_package(Python3 COMPONENTS Interpreter REQUIRED) makes a Python 3 interpreter a hard configure-time dependency of the C test suite, which it was not before. I am not calling this a defect: the fail-open alternative — skip the test when Python is missing — is what .ai/security.md warns about, a security suite that silently collects nothing and reports green. REQUIRED is the right choice. It is undocumented, though: nothing in the build prerequisites says the test suite now needs Python. |
One line in the build-prerequisites section of README.md / CONTRIBUTING.md. |
| 7 | Low | services/crypto/src/ed25519_verify.c:122-124 |
if (signature[63] & 224) is now strictly subsumed by sc_is_canonical — any S < L has those bits clear. Harmless and arguably worth keeping as a cheap early-out, but a later reader will wonder which one is authoritative. |
Either delete it, or extend the comment at :157-158 to say it is retained deliberately as a fast path rather than as a check. |
| 8 | Low | net/src/net_posix.c:16,216; tests/test_net.c:213-250 |
Third PR in this batch carrying these identical hunks (#119, #121, #122 all include the #116 repair rather than depending on it). Two of the three will conflict or no-op. |
One merge-order line in the body, as the author already did for #118/#115. The changes are correct; reviewed under #119. |
The fix itself is correct, and I checked it rather than taking the body's word. The
L table at services/crypto/src/ed25519_verify.c:134-139 is the canonical little-endian
encoding of 2^252 + 27742317777372353535851937790883648493: bytes 0-15 are
0x14DEF9DEA2F79CD65812631A5CF5D3ED reversed, bytes 16-30 are zero, byte 31 is 0x10,
which is 2^252. The loop at :141-146 walks most-significant byte first and returns 1
only on a strictly-smaller byte, 0 on a strictly-larger one, and 0 on full equality — so
S ∈ [0, L) accepts and S ≥ L refuses, S == L included, as the trailing comment says.
The call site at :162-165 sits after the cheap mask and before ge_frombytes_negate_vartime,
so a malformed S costs nothing. Variable-time early exit is fine and the justification
given is right: S is a public component of the signature, not a secret, so
.ai/security.md's constant-time rule does not apply here.
No check is weakened by this PR: nothing disabled, no assertion removed, no lint loosened.
All 26 required checks pass in checks.txt for this head.
Architecture conformance
§14.1 Security principles — conforms, and the PR is a direct answer to one of them.
"Do not invent cryptographic primitives" is respected: this is ref10 with a range check
added, and the range check is RFC 8032 §5.1.7 verbatim. "Reject invalid and low-order
public keys explicitly; do not assume the library did" (.ai/security.md) — that is what
ed25519_key_has_prime_order and now sc_is_canonical do.
"Integrate key management across eBoot, eSec, eOTA and release signing (§14.1) — review
it as one system, not four." This PR is the first thing in this batch that takes that
seriously, and it is the right shape. §14.1's actual wording is about key management,
though; it says nothing about two repositories carrying independent implementations of the
same verification primitive, which is the condition that let eBoot#73 survive after this
side had fixed it. Proposal appended to .ai/autoreview/proposals/2026-09.md.
§5.1 architectural law — conforms. services/crypto depends on nothing above it; the
new test links eos_crypto only. The eBoot coupling is data and a digest, not a build or
link dependency, which is the correct way to couple a Tier-1 boot component to a Tier-2
platform service without either depending on the other.
§21 tier placement — conforms. Crypto in eos under services/crypto is the "eSec
subset" §4 places in EmbeddedOS Core.
Proposed changes
- Pin the digest in
test_ed25519_contract.cand compare it (finding 1). Land this
first — until it exists, nothing else in the contract mechanism is load-bearing. - Reorder the include directories, add
tests/vectors/to.gitignore, fix the body's
re-derivation command (finding 2). - Add the
sc_is_canonicalboundary unit test (finding 4). No new dependencies. - Extend the generator with non-canonical public-key encodings and
S = L/S = L-1
(finding 3), regenerate, update the pinned digest in both repos in the same pair of
PRs. This is the one step that must be coordinated with eBoot#89. positives != 3; document the Python prerequisite; resolve the redundant mask.
Steps 1–3 are local to this repo and independent of eBoot. Step 4 is the cross-repo one
and should not block the rest.
Not checked
- I did not build this branch, run
ctest, or execute the corpus. The76/76,
3 of 76 wrongbefore the fix,35/35, and the1059feb…digest are the author's
figures. The independent evidence ischecks.txt, green for this head. - I did not read
tools/gen_ed25519_contract_vectors.py. Finding 3 is about what the
body says the corpus contains (64 + 3 + 6 + 3 = 76); I did not verify that the generator
produces those vectors, that the low-order points are the correct eight, or that the
RFC 8032 §7.1 values are transcribed correctly. Given that this file is the sole source
of the corpus and the digest, it deserves a line-by-line read by someone — I am flagging
that as a gap in this review, not as a finding against the PR. - I did not look at eBoot#89. The claim that the two corpora are byte-identical, and that
eBoot'ss_is_canonical()has always done this, is unverified here —eBootis not in
this run's bundle set. Finding 1 exists precisely because nothing automated verifies it
either. - The 200-signature over-rejection check in the 2026-09-01 comment was run outside CI on
code I cannot see. Finding 4 does not depend on it being wrong; it depends on it not
being repeatable. - Whether
ge_frombytes_negate_vartimein this tree acceptsy ≥ pencodings — finding 3
states the general ref10 behaviour, and I did not read this repo's copy of that function
to confirm it here. That is one reason the vector is worth adding: it answers the
question by running it.
Automated architecture review of e1152b0938de — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…ry directly Answers the review on embeddedos-org#122. The malleability fix itself was found correct; these are the findings around it. Finding 1 (High) -- the digest was printed and never compared, so the shared contract could not fail. EOS_ED25519_CONTRACT_DIGEST came from the header this repo's own generator had just written, a hash taken over its own output, so editing the generator here and not in eBoot produced a self-consistent corpus with a new digest, a green test, and a divergence visible only to a human reading two CI logs. Now pinned as a literal in committed source, together with the vector count and accept count, which are generated for the same reason and were equally unfalsifiable. The eBoot twin (embeddedos-org#89) pins the identical value. Finding 4 (Medium) -- sc_is_canonical()'s accept side had no committed test. Through the public API the boundary is unreachable: S == L and a failed group equation are both simply 0, so an off-by-one refusing a valid S = L - 1 would satisfy every vector in the corpus and reject real signatures only in the field. It is a pure function of 32 bytes, so tests/test_ed25519_canonical_s.c tests it directly -- 0, 1, L-1, L, L+1, 2^252, 2^252+2^251 and all-ones -- with no signing key and no `cryptography` module. ed25519_verify.c is compiled a second time with EOS_ED25519_TEST_HOOKS to give the function external linkage; the shipped build is unchanged. This is the case the finding was about, reproduced. With `S == L` accepted: test_ed25519_canonical_s [FAIL] at the S = L case test_ed25519_contract [PASS] all 76 vectors The corpus cannot see it. The direct test can. Also corrected while writing it: 2^252 is BELOW L (L = 2^252 + 2.77e37), so it is in range. The value that is both non-canonical and passed by ref10's `signature[63] & 224` test is 2^252 + 2^251 -- 0x18 & 224 == 0. Both are now asserted, the second alongside the mask check that shows why the partial test misses it. Finding 2 (Medium) -- tests/vectors/ed25519_contract_vectors.h is now committed rather than generated. CMAKE_CURRENT_SOURCE_DIR preceded CMAKE_CURRENT_BINARY_DIR on the include path, so a stale source-tree copy would silently win over the generated one -- and the PR body's own re-derivation command wrote to exactly that path. Committing removes the shadow, makes the corpus auditable from a tag, and drops find_package(Python3 ... REQUIRED) from a pure-C suite. NOT fixed here, and stated rather than left implied: finding 3, the corpus omitting the y >= p (non-canonical public key) family. That is the class most likely to diverge between the two verifiers and it is worth adding -- but adding vectors changes the digest, which both repos now pin, so it has to land in eos and eBoot together in one coordinated change. That is the mechanism working as intended, and it is a follow-up rather than something to slip into this PR. Verified: ctest 41/41 PASS test_ed25519_contract 76 vectors, 3 accept, 73 refuse test_ed25519_canonical_s PASS rebased onto master; conflicts in tests/CMakeLists.txt and services/crypto/src/ed25519_verify.c resolved additively -- master's ed25519_public_key_is_usable() (embeddedos-org#120) and this branch's sc_is_canonical() both kept. Refs embeddedos-org#122
e1152b0 to
72e0f56
Compare
…ARM file Answers the review on embeddedos-org#126. Finding 1 (Medium) -- the head carried an undisclosed second change, the test_crypto_ed25519_loworder registration, which contradicted this PR's own 2026-09-02 comment ("this PR changes one file ... and does not touch tests/"). It was also the fourth open copy of that registration, at a different position from embeddedos-org#127's. Dropped: the branch is rebased to the toolchain change alone, and embeddedos-org#127 is the one to take for the registration -- it restores the four lines to their original position, keeps the crypto suites grouped, and is the position embeddedos-org#122 is already written against. Finding 2 (Medium) -- nothing would catch this coming back. Every ARM toolchain here sets CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY and no cross build links an executable, so `Cross-compile ARM Cortex-M4` passed identically with the duplicate present and absent; it passed on the unfixed commit. The defect surfaced by accident when embeddedos-org#118 briefly put cmd/eos into the cross build, and embeddedos-org#118 has since gated that to host builds -- removing the accident too. Adds tests/unit/test_toolchain_specs.py, modelled on test_cmake_test_registration.py: parse every toolchains/*.cmake, extract each -specs=<file>, and fail if one appears in more than one variable that reaches a link line (CMake links through the compiler driver, so C/CXX flags do). A second test asserts specs are declared on the linker flags rather than the compiler flags, which is the arrangement that turns into a duplicate the moment someone adds the linker entry that looks missing. No ARM toolchain needed; runs in the existing pytest job. Finding 3 (Low) -- arm-none-eabi-r5.cmake put --specs=nosys.specs in CMAKE_C_FLAGS_INIT with nothing in CMAKE_EXE_LINKER_FLAGS_INIT. Not broken today, and one added linker -specs= away from the same failure. Moved, so all three ARM bare-metal toolchains state it the same way and the new guard is green across all of them. Verified: pytest tests/unit/test_toolchain_specs.py 2 passed pytest tests/ all pass discrimination: reintroducing the duplicate in arm-cortex-m4.cmake gives arm-cortex-m4.cmake: -specs=nosys.specs is set in CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT, so it reaches the link line more than once on both tests, so the guard catches the original defect rather than restating the fix. No ARM toolchain on this machine, so the cross link itself is NOT verified here; that remains CI's to show. Refs embeddedos-org#126
…ARM file Answers the review on embeddedos-org#126. Finding 1 (Medium) -- the head carried an undisclosed second change, the test_crypto_ed25519_loworder registration, which contradicted this PR's own 2026-09-02 comment ("this PR changes one file ... and does not touch tests/"). It was also the fourth open copy of that registration, at a different position from embeddedos-org#127's. Dropped: the branch is rebased to the toolchain change alone, and embeddedos-org#127 is the one to take for the registration -- it restores the four lines to their original position, keeps the crypto suites grouped, and is the position embeddedos-org#122 is already written against. Finding 2 (Medium) -- nothing would catch this coming back. Every ARM toolchain here sets CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY and no cross build links an executable, so `Cross-compile ARM Cortex-M4` passed identically with the duplicate present and absent; it passed on the unfixed commit. The defect surfaced by accident when embeddedos-org#118 briefly put cmd/eos into the cross build, and embeddedos-org#118 has since gated that to host builds -- removing the accident too. Adds tests/unit/test_toolchain_specs.py, modelled on test_cmake_test_registration.py: parse every toolchains/*.cmake, extract each -specs=<file>, and fail if one appears in more than one variable that reaches a link line (CMake links through the compiler driver, so C/CXX flags do). A second test asserts specs are declared on the linker flags rather than the compiler flags, which is the arrangement that turns into a duplicate the moment someone adds the linker entry that looks missing. No ARM toolchain needed; runs in the existing pytest job. Finding 3 (Low) -- arm-none-eabi-r5.cmake put --specs=nosys.specs in CMAKE_C_FLAGS_INIT with nothing in CMAKE_EXE_LINKER_FLAGS_INIT. Not broken today, and one added linker -specs= away from the same failure. Moved, so all three ARM bare-metal toolchains state it the same way and the new guard is green across all of them. Verified: pytest tests/unit/test_toolchain_specs.py 2 passed pytest tests/ all pass discrimination: reintroducing the duplicate in arm-cortex-m4.cmake gives arm-cortex-m4.cmake: -specs=nosys.specs is set in CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT, so it reaches the link line more than once on both tests, so the guard catches the original defect rather than restating the fix. No ARM toolchain on this machine, so the cross link itself is NOT verified here; that remains CI's to show. Refs embeddedos-org#126
…ry directly Answers the review on embeddedos-org#122. The malleability fix itself was found correct; these are the findings around it. Finding 1 (High) -- the digest was printed and never compared, so the shared contract could not fail. EOS_ED25519_CONTRACT_DIGEST came from the header this repo's own generator had just written, a hash taken over its own output, so editing the generator here and not in eBoot produced a self-consistent corpus with a new digest, a green test, and a divergence visible only to a human reading two CI logs. Now pinned as a literal in committed source, together with the vector count and accept count, which are generated for the same reason and were equally unfalsifiable. The eBoot twin (embeddedos-org#89) pins the identical value. Finding 4 (Medium) -- sc_is_canonical()'s accept side had no committed test. Through the public API the boundary is unreachable: S == L and a failed group equation are both simply 0, so an off-by-one refusing a valid S = L - 1 would satisfy every vector in the corpus and reject real signatures only in the field. It is a pure function of 32 bytes, so tests/test_ed25519_canonical_s.c tests it directly -- 0, 1, L-1, L, L+1, 2^252, 2^252+2^251 and all-ones -- with no signing key and no `cryptography` module. ed25519_verify.c is compiled a second time with EOS_ED25519_TEST_HOOKS to give the function external linkage; the shipped build is unchanged. This is the case the finding was about, reproduced. With `S == L` accepted: test_ed25519_canonical_s [FAIL] at the S = L case test_ed25519_contract [PASS] all 76 vectors The corpus cannot see it. The direct test can. Also corrected while writing it: 2^252 is BELOW L (L = 2^252 + 2.77e37), so it is in range. The value that is both non-canonical and passed by ref10's `signature[63] & 224` test is 2^252 + 2^251 -- 0x18 & 224 == 0. Both are now asserted, the second alongside the mask check that shows why the partial test misses it. Finding 2 (Medium) -- tests/vectors/ed25519_contract_vectors.h is now committed rather than generated. CMAKE_CURRENT_SOURCE_DIR preceded CMAKE_CURRENT_BINARY_DIR on the include path, so a stale source-tree copy would silently win over the generated one -- and the PR body's own re-derivation command wrote to exactly that path. Committing removes the shadow, makes the corpus auditable from a tag, and drops find_package(Python3 ... REQUIRED) from a pure-C suite. NOT fixed here, and stated rather than left implied: finding 3, the corpus omitting the y >= p (non-canonical public key) family. That is the class most likely to diverge between the two verifiers and it is worth adding -- but adding vectors changes the digest, which both repos now pin, so it has to land in eos and eBoot together in one coordinated change. That is the mechanism working as intended, and it is a follow-up rather than something to slip into this PR. Verified: ctest 41/41 PASS test_ed25519_contract 76 vectors, 3 accept, 73 refuse test_ed25519_canonical_s PASS rebased onto master; conflicts in tests/CMakeLists.txt and services/crypto/src/ed25519_verify.c resolved additively -- master's ed25519_public_key_is_usable() (embeddedos-org#120) and this branch's sc_is_canonical() both kept. Refs embeddedos-org#122
72e0f56 to
cce53df
Compare
…removed `CI — eos` and `Python Unit Tests` are red on master (769191a), and both fail for one reason: these suites exist in tests/ but no add_executable() in tests/CMakeLists.txt builds them, so they never run: ['test_crypto_ed25519_loworder.c'] 3aa8644 (embeddedos-org#93, "remove duplicate test targets from tests/CMakeLists.txt") removed all four lines that build and register test_crypto_ed25519_loworder. It was not a duplicate — there was exactly one registration before that commit, and it took it. What stopped running is the suite that guards embeddedos-org#101: Ed25519 rejecting low-order public keys. A low-order key makes every term of the verification equation collapse to the identity regardless of the message, so a signature of all zeros verifies against any content at all. The fix is still in services/crypto/src/ed25519_verify.c and still correct; nothing has been checking it since embeddedos-org#93 merged, and nothing would have noticed if a later change had removed it too. Restored verbatim from 3aa8644^, in its original position before test_crypto_sha512. No other file changes. The guard that caught this is test_cmake_test_registration.py, added recently. It did exactly its job — this is the first thing it found. Verified: test_cmake_test_registration.py -> 5 passed (1 failed on master) pytest tests/ -> 15 passed (14 passed 1 failed on master) test_crypto_ed25519_loworder -> 5/5 tests passed ctest -> 39/39 passed (master builds 38)
…agged Answers the second round on embeddedos-org#122. Both findings are defects in my previous commit. The digest pin did not cover the corpus. EOS_ED25519_CONTRACT_DIGEST is a #define inside the generated header; EOS_ED25519_CONTRACT_EXPECTED is a #define in the driver. The test compared two string literals and never hashed a vector, so editing a committed vector changed the data and left the value it was compared against untouched. Raised on the eBoot twin (embeddedos-org#89) and stated there to apply here too; it did. contract_digest() now recomputes SHA-256 over the vectors at run time in the generator's serialisation -- public_key[32] || signature[64] || message[message_len] || (uint8_t)expect_accept -- via eos_sha256_* from eos/crypto.h. It compares the result to the pinned literal and to the header's own claim, so a corpus and a header from different generator runs are caught. Verified with the probe from the eBoot review. Changing one byte of the order-1 identity public key: [FAIL] corpus digest changed expected 1059febe...282a2 got 55370deb...cae9 exit 1 That `got` value is byte-identical to the one eBoot's driver produces for the same edit, which is independent confirmation that the two committed corpora really are the same file. Worth recording because it nearly cost me the finding: the first probe run reported PASS. The build had not picked up the header change, so the binary was stale. `touch` on the driver and a rebuild gave the failure above. A probe that does not rebuild proves nothing -- check that the link step ran. CodeQL alert 405, "Static function sc_is_canonical is unreachable" -- caused by the EOS_ED25519_TEST_HOOKS macro I added, which gave the symbol a linkage that varied by build. Removed: the function is static again, and tests/test_ed25519_canonical_s.c includes the translation unit to reach it, which is the ordinary way to test a static and needs no conditional compilation in a crypto source file. eos_crypto is still linked for the ge_*/sha512 helpers the included unit calls -- a static archive contributes only objects needed for undefined symbols, and ed25519_verify.c.o is not one, because the test object already defines everything in it. Verified: ctest 41/41 PASS test_ed25519_canonical_s PASS, links with no duplicate symbols test_ed25519_contract 76 vectors, 3 accept, 73 refuse byte-flip probe FAILS (after a real rebuild) off-by-one probe, `S == L` accepted: test_ed25519_canonical_s FAILS test_ed25519_contract still exit 0 so the direct boundary test still discriminates where the corpus cannot. Refs embeddedos-org#122
| * compiling ed25519_verify.c a second time with a macro that makes the symbol | ||
| * external -- gave the function a linkage that varied by build, which static | ||
| * analysis reads as an unreachable static in the ordinary build. */ | ||
| #include "../services/crypto/src/ed25519_verify.c" |
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#122 "fix(ed25519): reject a non-canonical S, and share the contract with eBoot"
head: e3ff3b2 author: Kartikey1306 ci: fail (both failures inherited from eBoot master — see CI)
Verdict: The security fix is correct and I confirmed it the hard way — with the call removed, 3 of 76 vectors accept a non-canonical S and the same message verifies under two distinct signatures. sc_is_canonical() is right, the corpus discriminates, and the generator re-derives the committed file byte-for-byte. Two things do not hold up: the file is not byte-identical to eBoot#89's copy, and the mechanism detects a unilateral edit rather than the divergence the PR is named for.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium | pr.json body; tests/vectors/ed25519_contract_vectors.h:14 |
"byte-identical to eBoot #89's copy" and "digest … identical to eBoot #89's copy" — the second is true, the first is not. Measured: sha256sum of eBoot#89's tests/vectors/ed25519_contract_vectors.h is 489b1fc0662187841c58e12c650ca1a25a974e4fafe4d4318f4fad62b7e4b5cf; this head's is df9abfb1543bab747c581d644c0af6861ebba22d7f02c5d9f7ae2b13c045b286. The entire difference is the header comment — eBoot#89 carries a three-line explanation of the digest mechanism where this carries one line — and the two generators' docstrings differ to match. All 76 vectors and the embedded digest are identical, and both sides pin the same 1059feb… literal. So the contract holds and the file-level identity the body asserts does not. It matters because "byte-identical" is the claim a reviewer would spot-check by hashing both files, and it fails. |
Regenerate both copies from one generator so the files really are identical, or narrow the claim to "the 76 vectors and their digest are identical across both repos". The narrower statement is both true and stronger, and it is what eBoot#89's revised comment already says. |
| 2 | Medium | tests/test_ed25519_contract.c:~40; tools/gen_ed25519_contract_vectors.py |
The mechanism catches a unilateral edit, not divergence — which is what the title promises. Each repo recomputes the digest over its own vectors and compares it to a literal pinned in its own committed source. That correctly fails a hand-edited vector. It does not fail two repos holding different corpora: regenerate on one side with a changed generator, update that side's literal in the same commit, and both suites stay green while the two verifiers are held to two different contracts. That is not hypothetical — the generators have already diverged, verified by diffing eBoot#89's tools/gen_ed25519_contract_vectors.py against this one. Comment-only today, but it is the same file drifting by hand that the PR body identifies as the original problem ("the identical file would otherwise be checked in to both eBoot and this repo and kept in step by hand"), reintroduced one level up as the digest literal. |
The durable fix is one source both repos consume — a submodule, or publishing the header as a Registry artifact with a version and a hash (§11, and .ai/platform.md: "A consumer depends on a component with a version range, never on a git URL or a path into another repo's tree"). Failing that, a CI step in one repo that fetches the other's copy and diffs it. At minimum, state in both files that the digest literal is hand-synchronised and must change in both repos in the same change, because nothing enforces it. This is already an open architecture proposal — see below — so no new one was appended. |
| 3 | Medium | services/crypto/src/ed25519_verify.c:159-165; tests/CMakeLists.txt:99, :113-116 |
Three stale comment blocks describe an approach that was abandoned, and two contradict the sentence beside them. (a) ed25519_verify.c:159 opens "Linked, not static, when the test hook is on", and :165 says "It stays static." The function is static. (b) tests/CMakeLists.txt:99 says "Compiles ed25519_verify.c a second time with EOS_ED25519_TEST_HOOKS so sc_is_canonical() has external linkage" — EOS_ED25519_TEST_HOOKS appears nowhere else in the repository (grepped the whole tree; that comment is its only occurrence), and the test reaches the static by #include "../services/crypto/src/ed25519_verify.c" (tests/test_ed25519_canonical_s.c:27), which the comment four lines below correctly describes. (c) tests/CMakeLists.txt:113 says the vector header "is produced into the build tree rather than committed"; :116 says it "is committed, not generated at build time". It is committed — git ls-files tests/vectors/ confirms. In a crypto translation unit and its build description, a comment asserting the opposite of the code is a hazard rather than noise: the next person defines the macro that does not exist, or goes looking for a generated header. |
Delete the superseded paragraphs. In each case the surviving text already explains the real design correctly, so this is subtraction only. |
| 4 | Low | pr.json body "Validation" |
ctest -> 35/35 passed no longer describes this head. I measure 41/41 — 38 on master plus the three this PR registers (test_crypto_ed25519_loworder, test_ed25519_canonical_s, test_ed25519_contract). Stale after the rebases, same as on #119. Worth refreshing because the body is the record of what was actually run. |
Re-run and quote the current figure. |
What is right, verified rather than assumed
-
sc_is_canonical()is correct. The little-endianL[32]matches the standard encoding of2^252 + 27742317777372353535851937790883648493. The loop from byte 31 down returns 1 on the first byte wheres[i] < L[i]and 0 wheres[i] > L[i], falling through toreturn 0when all 32 are equal — so it returns 1 exactly whenS < L, andS == Lis correctly refused. That is RFC 8032 §5.1.7's[0, L). -
The early return is fine.
.ai/security.mdrequires constant-time comparison of secrets;Sis half the signature and public, so the position of the first differing byte leaks nothing. The comment makes that argument explicitly, which is the right thing to have written down. -
Negative control. I removed the
sc_is_canonical()call and rebuilt:[FAIL] rfc8032_test1_empty_noncanonical_S expected refuse, got ACCEPT [FAIL] rfc8032_test2_1byte_noncanonical_S expected refuse, got ACCEPT [FAIL] rfc8032_test3_2byte_noncanonical_S expected refuse, got ACCEPT 6 accepted, 70 refused, 3 wrongSix accepts where three are correct —
(R, S)and(R, S + L)both verifying, which is the malleability the PR describes, reproduced. The corpus discriminates; it is not a suite that would pass either way. -
The corpus is genuinely re-derivable.
python3 tools/gen_ed25519_contract_vectors.pyreproduced the committed header byte-for-byte (diffclean), printingvectors: 76 accept: 3 reject: 73anddigest: 1059febedae2b3e3dfaa1ef5b419fb37ed7f0d53b377a3250b53b95a546282a2. Pure stdlib, nocryptographyneeded, so a reviewer can check it without installing anything. -
The positive vectors are load-bearing, as claimed. Three accept cases among 73 refusals, and the driver fails explicitly if the corpus loses them — so a verifier that refused everything does not pass.
-
The production path is covered. eos has exactly one Ed25519 verifier (
services/crypto/src/ed25519_verify.c) and one non-test caller:services/pkg/eos_pkg.c:235, reached fromeos_pkg_verify(:520) and:632. The fix is insideed25519_verify(), so that path gets it. Being precise about impact:eos_pkguses the signature only to verify, never as a cache key or an identifier, so the dedupe/replay scenario in the PR body is not realised by any in-repo consumer today. The fix is required by RFC 8032 regardless, and it removes the hazard before the Registry starts indexing artifacts by signature (§11).
CI
Two red, neither caused by this PR:
Cross-compile ARM64 kernel—.github/workflows/eos-simulation.ymlchecks outembeddedos-org/eBoot@masterand builds it; eBoot'smasterdoes not compile (eBoot/include/eos_image.h:135,error: 'eos_image_header_t' has no member named 'reserved'). Reproduced in a clean worktree: three pre-existing errors from one bad merge, all covered by eBoot#94.Full-stack integration summary— downstream of it. On this head that ismaster's version of the job, whose onlyexit 1testsneeds.simulate-qemu.result, andsimulate-qemuis skipped, so it fails under the old rule too. (Note for eos#121, which rewrites this job: that PR's version of it exits 127 on a missing checkout — see the review there.)
mention and deep-review reporting skipping is claude-code-review.yml behaving normally on a pull request.
Reproduced locally at head e3ff3b2:
cmake -S . -B bt -DEOS_BUILD_TESTS=ON && cmake --build bt # rc=0, no errors
ctest --test-dir bt --no-tests=error # 41/41 passed
./bt/tests/test_ed25519_contract # digest 1059feb…, 76 vectors, 3 accepted, 73 refused, 0 wrong
./bt/tests/test_ed25519_canonical_s # the [0, L) boundary holds on both sides
python3 tools/gen_ed25519_contract_vectors.py | diff - tests/vectors/… # identical
Architecture conformance
Conforms, and it is one of the few changes that acts on §14.1 rather than around it.
- §14.1, "Do not invent cryptographic primitives" — conforms. Nothing new is invented; a range check RFC 8032 mandates is added to an existing ref10-derived verifier, and the old
signature[63] & 224test is kept rather than replaced, so the change is purely additive. - §14.1, "Integrate key management across eBoot, eSec, eOTA and release signing" and
.ai/security.md's "review it as one system, not four" — this is the PR's real contribution. Two repos each carrying an Ed25519 verifier with opposite return conventions is exactly the state where a check present on one side and missing on the other cannot be noticed, and a shared vector corpus is the correct instrument. Finding 2 is that the instrument is not yet load-bearing across the repo boundary. - §11 /
.ai/platform.md, provenance — supported. The production caller is package-signature verification, and a malleable signature on a.eappis a provenance defect waiting for the first consumer that indexes by signature. - §21 tier placement / §5.1 — correct and unchanged.
services/cryptosits belowservices/pkg; the test linkseos_crypto; nothing points up a tier and no cross-repo dependency is added — the corpus is data, copied, not linked. .ai/security.md, TCB strictness — this is crypto code and was read as such. The one place the stricter standard bites is finding 3: astaticfunction whose comment says it is not static, in a file where linkage and reachability are the properties a reader checks first.- Weakened checks — none. Three tests added, one predicate tightened, nothing disabled.
Proposed changes
-
Findings 1 and 3 — text only, no behaviour change: sync the header comment with eBoot#89's wording (which fixes finding 1 by making the files identical), and delete the three superseded paragraphs.
-
Finding 4 — re-run and quote 41/41.
-
Finding 2 is the one worth doing properly and it does not belong in this PR. Land this as is once 1, 3 and 4 are addressed; open a follow-up for real cross-repo enforcement.
No new architecture proposal was appended, because one already covers this:
.ai/autoreview/proposals/2026-09.md, "2026-09-02 — A cryptographic primitive with two implementations has no owner and no cross-check" (trigger eBoot#89, eBoot#86). It asks §14.1 to require a shared conformance corpus that is "byte-identical in each repository, with a digest asserted as a committed literal on both sides", and it states finding 2's conclusion in one line: "Two independent self-consistency checks do not detect divergence."Worth recording as progress against it: that proposal notes eBoot#89 "as submitted, prints its digest rather than asserting it and generates the corpus into the build tree rather than committing it, so it does not yet satisfy the second bullet". This head satisfies both halves — the corpus is committed (
git ls-files tests/vectors/confirms) and the digest is asserted against a pinned literal intests/test_ed25519_contract.c, which is also what eBoot#89's current head does. What remains open is the last clause of that bullet, "so that a change reaching one copy and not the other fails the build": nothing does that yet, and the generators have already drifted. -
Merge order: this PR registers
test_crypto_ed25519_loworderintests/CMakeLists.txt, as do #118, #119 and #126, and #125 is the PR that exists to do it. Land #125 first, then rebase and drop the duplicated hunk from all four.
I opened no fix PR. Nothing here is rated High, and findings 1–4 are all inside this PR's own files.
Not checked
- Over-rejection was not independently verified. The 10:49 comment reports 200 genuine signatures accepted with the largest
S/Lat 0.999861, generated externally withcryptography. That is the right check and the honest caveat aboutS == Lbeing indistinguishable through the public API is correct, but I did not reproduce it — eos is verify-only (ed25519_sign()is declared ined25519.hand implemented nowhere, confirmed), so it cannot be reproduced in-process, and the script was not committed. What I can confirm is thattest_ed25519_canonical_s.creaches the predicate directly and passes, which covers the boundary by construction rather than by sampling. - eBoot#89 was not built or run. I diffed its committed vector file and generator against this head's, which is what findings 1 and 2 rest on. Whether eBoot#89's driver actually asserts the digest at run time was read from its source, not observed.
- No timing measurement was taken. The constant-time argument for
sc_is_canonical()is thatSis public, not that the function is constant-time — it is not, and does not need to be. No claim about the rest of the verifier's timing behaviour is made or checked. - No fuzzing.
.ai/security.mdrequires fuzz coverage for externally reachable parsers, anded25519_verify()is reached fromeos_pkg_verify()on attacker-supplied.eappbytes.tests/fuzz/exists; there is no harness for this path and this PR adds none. Pre-existing gap. - The 64 low-order vectors were taken as correct on the generator's word. I verified they are refused, not that the eight points are the eight points of order dividing 8.
- Cross-compilation was not exercised.
sc_is_canonical()is endian-independent by construction (it indexes bytes), but no big-endian or 32-bit target was built.
Automated architecture review of e3ff3b2661a6 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…e comments Addresses srpatcha's review findings 1 and 3 on embeddedos-org#122. Text and comments only; no behaviour change, and the 76 vectors and their digest are untouched. Finding 1 -- the body claimed the corpus was byte-identical to eBoot#89's copy and it was not: the two generators had drifted in their docstrings and in the header comment they emit, so the files differed by three comment lines while all 76 vectors and the digest matched. Adopted eBoot#89's wording verbatim -- it is the more accurate description, since each side recomputes the digest and compares it to a pinned literal rather than merely printing it -- and regenerated. Both repos' tools/gen_ed25519_contract_vectors.py and tests/vectors/ed25519_contract_vectors.h are now byte-identical: sha256 489b1fc0662187841c58e12c650ca1a25a974e4fafe4d4318f4fad62b7e4b5cf which is the value the review measured for eBoot's copy, so the spot-check the finding describes -- hash both files -- now passes. Finding 3 -- three comment blocks described an approach that was abandoned, two of them contradicting the code beside them: (a) ed25519_verify.c opened "Linked, not static, when the test hook is on" six lines above "It stays static", and the function is static. (b) tests/CMakeLists.txt described compiling ed25519_verify.c a second time with EOS_ED25519_TEST_HOOKS. That macro occurred exactly once in the tree -- in that sentence. The next line already describes what the test really does (#include the translation unit). (c) the paragraph saying the vector table "is produced into the build tree rather than committed" sat directly above one saying it is committed. It is committed; the generate-at-build-time paragraph was mine, left behind when that approach was reverted, and it is the half that was false. In each case the surviving text already explained the real design, so this is subtraction. Verified: cmake+ctest 41/41 (the count finding 4 asks for, not the 35/35 still in the body), test_ed25519_contract reports digest 1059feb... over 76 vectors with 3 accepted and 0 wrong, and test_ed25519_canonical_s still passes.
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#122 "fix(ed25519): reject a non-canonical S, and share the contract with eBoot"
head: fedc7e2 author: Kartikey1306 ci: pending (two red are a superseded run — see CI)
Verdict: The two claims that did not hold last round now do. Measured: tests/vectors/ed25519_contract_vectors.h and tools/gen_ed25519_contract_vectors.py are sha256-identical to eBoot#89's copies at 64a7f63, the generator re-derives the committed header byte-for-byte, and the digest guard fails on a mutated vector — I broke one and watched it. One thing is wrong in the file that was edited to achieve that: its description of the guarantee is now both ungrammatical and, in the part that parses, false.
Last round's findings 1 and 3 are fixed and I verified both rather than reading the diff. Findings 2 and 4 are unchanged and not repeated except where the new text touches them.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium | tests/vectors/ed25519_contract_vectors.h:14-18, emitted by tools/gen_ed25519_contract_vectors.py:113-117 |
The comment that documents the cross-repo guarantee overstates it, and half a sentence was left behind by the edit. As committed it reads: "Both repos compile this identical file. Each side's driver recomputes the digest over these vectors and compares it to a literal pinned in its own committed source, so an edit here fails there. Divergence is / change on one side that does not reach the other is visible in review as / two different digests." The last two lines are the tail of the previous wording, so the sentence starting "Divergence is" has no predicate. More importantly "so an edit here fails there" is not true: editing this file fails this repo's test_ed25519_contract, and eBoot notices nothing at all — which is precisely last round's finding 2, restated in the file as though it were solved. The author's own generator docstring at :13-19 gets it right ("compared against a literal pinned in committed test source"), and so does the test's failure message ("Re-derive, confirm eos and eBoot agree, then update the pin in BOTH" — an instruction to a human, because nothing enforces it). In a file whose subject is what two verifiers are held to, the summary of the mechanism being wrong in the optimistic direction is the version that matters. Compounding it: the file is byte-identical to eBoot#89 (489b1fc0662187841c58e12c650ca1a25a974e4fafe4d4318f4fad62b7e4b5cf both sides, measured), so the garbled and overstated text is now in both repos and fixing it requires the coordinated two-repo change that has no enforcement. |
In gen_ed25519_contract_vectors.py:113-117, replace the whole block with something that says what actually happens and no more — e.g. "Both repos compile this identical file. Each side's driver recomputes the digest over these vectors and compares it to a literal pinned in its own committed test source, so a hand edit fails that side's build. Nothing yet compares the two repos' copies to each other: keeping them identical is a manual step, and the pin must be updated in both in the same change." Then regenerate, and land the identical result in eBoot#89 in the same review pass. |
| 2 | Low | tests/CMakeLists.txt:96-104 |
Reading the surviving comment after last round's deletions: it now explains why ed25519_verify.c is not listed as a source (test_ed25519_canonical_s.c #includes the translation unit) and why eos_crypto is still linked. That is correct and clear. What it no longer says anywhere is that including a .c file makes this test compile the verifier without the flags eos_crypto is built with. If the library ever gains a target-level define or optimisation option that changes sc_is_canonical's behaviour, the boundary test would be checking a differently-compiled copy and would still pass. No such option exists today — checked services/crypto/CMakeLists.txt via the configured build, target_compile_definitions is empty. |
One sentence in the same comment block recording that the included copy is compiled with the test target's flags, so a future target_compile_definitions(eos_crypto ...) must be mirrored here or the test stops testing the shipped code. |
What I verified rather than took on trust
Everything below was run at fedc7e27 in a detached worktree; the working checkout was not touched and the worktree was removed afterwards.
-
Byte-identity across repos, which the body claims and which failed last round. Now true for both files:
eBoot#89 @64a7f63 tests/vectors/ed25519_contract_vectors.h 489b1fc0662187841c58e12c650ca1a25a974e4fafe4d4318f4fad62b7e4b5cf eos#122 @fedc7e2 tests/vectors/ed25519_contract_vectors.h 489b1fc0662187841c58e12c650ca1a25a974e4fafe4d4318f4fad62b7e4b5cf tools/gen_ed25519_contract_vectors.py b9fa6f7fd7f056a72a2c5c87071467567251ce512d99c0981951669fb8576d6a (both, `diff` empty)The generator drift reported last round is gone.
-
The corpus is re-derivable.
python3 tools/gen_ed25519_contract_vectors.pyprintedvectors: 76 accept: 3 reject: 73anddigest: 1059feb…, anddiffagainst the committed header was empty. Pure stdlib, nocryptographyrequired. -
The digest guard is load-bearing now, not printed.
tests/test_ed25519_contract.c:98compares the recomputed digest againstEOS_ED25519_CONTRACT_EXPECTEDpinned in the committed test source,:111against the header's own#define,:118againstEOS_ED25519_CONTRACT_EXPECTED_COUNT(76) and:156againstEOS_ED25519_CONTRACT_EXPECTED_ACCEPTS(3). Negative control — I flipped one byte of the first vector array and rebuilt:[FAIL] corpus digest changed expected 1059febedae2b3e3dfaa1ef5b419fb37ed7f0d53b377a3250b53b95a546282a2 got 7ec432dc99d30b703cef300c800f4d4136d1e3583178954ff4c87c540215d620 rc=1Restored,
rc=0. That closes last round's findings 1 and 5 — the mechanism fails when it should, and the accept count is asserted rather than merely being non-zero. -
The suite is green at this head.
cmake -DEOS_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Releaseconfigured and built withrc=0, andctest --no-tests=errorreported 41/41 passed../bt/tests/test_ed25519_contract→3 accepted, 73 refused, 0 wrong;./bt/tests/test_ed25519_canonical_s→the [0, L) boundary holds on both sides. -
The three stale comment blocks from last round's finding 3 are gone, all three, and nothing was added in their place that contradicts the code — except finding 1 above.
The sc_is_canonical() implementation, the L constant, the call-site placement and the variable-time argument were verified in the previous round and are unchanged by this head's diff, which touches only comments in services/crypto/src/ed25519_verify.c. Not re-derived here.
Architecture conformance
§14.1 — conforms, and this head strengthens the part §14.1 is weakest on. "Do not invent cryptographic primitives" holds: ref10 plus the range check RFC 8032 §5.1.7 mandates, added rather than substituted. "Integrate key management across eBoot, eSec, eOTA and release signing … review it as one system" is what the shared corpus serves, and byte-identity across the two repos is now measured rather than asserted — which is the first time in this batch that a cross-repo claim in a PR body has been true when checked.
§5.1 and §21 — unchanged and correct. services/crypto depends on nothing above it; the new tests link eos_crypto; the eBoot coupling is copied data and a hand-synchronised digest literal, not a build or link edge. A Tier-1 boot component and a Tier-2 platform service sharing a test corpus by copy is the right shape under §5.1 — neither depends on the other.
The gap §14.1 still does not cover — two repositories each carrying an independent implementation of the same verification primitive, with nothing structurally able to detect that one has a check the other lacks — remains open and is already an architecture proposal: .ai/autoreview/proposals/2026-09.md, "2026-09-02 — A cryptographic primitive with two implementations has no owner and no cross-check". This head satisfies both of that proposal's stated bullets (corpus committed, digest asserted against a pinned literal, on both sides) and leaves its final clause — "so that a change reaching one copy and not the other fails the build" — unsatisfied. Finding 1 is the file claiming otherwise. No new proposal appended.
Still open from last round and not repeated as findings: the corpus has no y ≥ p non-canonical public-key family, which is the divergence class most likely to differ between two ref10-derived decoders (finding 3 last round); and the body's ctest -> 35/35 is stale — measured 41/41 here (finding 4 last round).
Proposed changes
- Finding 1, in the generator rather than the header, then regenerate. Land the identical text in eBoot#89 in the same pass — the two files must stay byte-identical, and the digest is unaffected because it is computed over the vector bytes, not the comment (confirmed: the digest is unchanged at
1059feb…across this head's comment rewrite). - Finding 2 — one sentence in
tests/CMakeLists.txt. - Refresh the body's
35/35to41/41. - Then this is ready as far as I can tell. The remaining item — real cross-repo enforcement — is a follow-up, not this PR's job, and the proposal already records it.
Merge order is unchanged: this PR registers test_crypto_ed25519_loworder in tests/CMakeLists.txt, as do #118, #119 and #126, and #125 exists to do exactly that. Landing #125 first lets all four drop the duplicated hunk.
I opened no fix PR: nothing here is rated High, and both findings are inside this PR's own files and would collide with the branch.
CI
Nothing conclusive on this head yet. Build & Test (Linux x86_64), Analyze (C/C++), Host Tests (x86_64), QEMU Boot Test, Cross-compile ARM64 kernel, Cross-compile ARM Cortex-M4 and Default configuration were all still pending when the bundle was cut. Run Python tests, Static Analysis, Validate third_party/ and the SBOM, eFab CAD pipeline validation and all ten Python legs are green.
The two red — Cross-compile ARM Cortex-M4 and Create GitHub Release, both duration 0, both from run 33783658521 — are a superseded ci.yml run cancelled by concurrency.cancel-in-progress, not failures. The live legs of the same workflow are the pending entries at runs 33783659556/33783659586.
Cross-compile ARM64 kernel will fail when it gets there, for a reason outside this PR: eos-simulation.yml builds embeddedos-org/eBoot@master, and eBoot's master (22d8f8b) does not compile — core/ed25519_verify.c defines point_is_identity twice (:281 and :338) and include/eos_image.h:135 asserts offsetof(eos_image_header_t, reserved) against a struct that no longer has that member. Verified by reading origin/master directly. eBoot#94 and #97 are the repairs, and eBoot#84 carries the same repair as unstated scope.
Local run at this head, which is the evidence I would rely on instead: 41/41 ctest passed, build rc=0, both new crypto tests green, generator re-derivation clean.
Not checked
- eBoot#89 was not built or run. Findings about cross-repo identity rest on
sha256sumanddiffof its committed files at64a7f63. Whether its driver asserts the digest at run time was read from its source, not observed. - Over-rejection near
Lwas not independently reproduced. The 200-signature external check in the 2026-09-01 comment is still uncommitted and eos remains verify-only (ed25519_sign()declared ined25519.h, implemented nowhere), so it cannot be reproduced in-process. What runs on every build istest_ed25519_canonical_s, which reaches the predicate directly and covers the boundary by construction — which is the stronger answer and was last round's finding 4, now closed. - The 64 low-order vectors were taken on the generator's word. I confirmed they are refused, not that the eight encodings are the eight points of order dividing 8.
- No fuzzing of this path.
.ai/security.mdrequires fuzz coverage for externally reachable parsers, anded25519_verify()is reached fromeos_pkg_verify()on attacker-supplied.eappbytes.tests/fuzz/exists and has no harness for it; this PR adds none. Pre-existing. - No cross-compilation and no timing measurement.
sc_is_canonical()indexes bytes and is endian-independent by construction, but no big-endian or 32-bit target was built. The constant-time argument is thatSis public, not that the function is constant-time — it is not, and does not need to be. - The comment in finding 1 was assessed as text, not by testing what it describes. That "an edit here fails there" is false follows from how the two drivers are written (each compares to its own pinned literal), which I read; I did not edit eos's copy and observe eBoot's CI stay green.
Automated architecture review of fedc7e27f71d — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…ARM file Answers the review on embeddedos-org#126. Finding 1 (Medium) -- the head carried an undisclosed second change, the test_crypto_ed25519_loworder registration, which contradicted this PR's own 2026-09-02 comment ("this PR changes one file ... and does not touch tests/"). It was also the fourth open copy of that registration, at a different position from embeddedos-org#127's. Dropped: the branch is rebased to the toolchain change alone, and embeddedos-org#127 is the one to take for the registration -- it restores the four lines to their original position, keeps the crypto suites grouped, and is the position embeddedos-org#122 is already written against. Finding 2 (Medium) -- nothing would catch this coming back. Every ARM toolchain here sets CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY and no cross build links an executable, so `Cross-compile ARM Cortex-M4` passed identically with the duplicate present and absent; it passed on the unfixed commit. The defect surfaced by accident when embeddedos-org#118 briefly put cmd/eos into the cross build, and embeddedos-org#118 has since gated that to host builds -- removing the accident too. Adds tests/unit/test_toolchain_specs.py, modelled on test_cmake_test_registration.py: parse every toolchains/*.cmake, extract each -specs=<file>, and fail if one appears in more than one variable that reaches a link line (CMake links through the compiler driver, so C/CXX flags do). A second test asserts specs are declared on the linker flags rather than the compiler flags, which is the arrangement that turns into a duplicate the moment someone adds the linker entry that looks missing. No ARM toolchain needed; runs in the existing pytest job. Finding 3 (Low) -- arm-none-eabi-r5.cmake put --specs=nosys.specs in CMAKE_C_FLAGS_INIT with nothing in CMAKE_EXE_LINKER_FLAGS_INIT. Not broken today, and one added linker -specs= away from the same failure. Moved, so all three ARM bare-metal toolchains state it the same way and the new guard is green across all of them. Verified: pytest tests/unit/test_toolchain_specs.py 2 passed pytest tests/ all pass discrimination: reintroducing the duplicate in arm-cortex-m4.cmake gives arm-cortex-m4.cmake: -specs=nosys.specs is set in CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT, so it reaches the link line more than once on both tests, so the guard catches the original defect rather than restating the fix. No ARM toolchain on this machine, so the cross link itself is NOT verified here; that remains CI's to show. Refs embeddedos-org#126
…Boot Two things, and the second found the first. eos and eBoot each carry their own Ed25519 verifier. Different code, opposite return conventions — this one returns 1 to accept, eBoot's returns EOS_OK — doing the same job on the same wire format. When only eos rejected low-order public keys, nothing in either repo could notice: there is no shared build, and the two are far too different for a source diff to say anything. That divergence is why eBoot embeddedos-org#73 survived here after this side had already fixed it. tests/vectors/ed25519_contract_vectors.h is the part that can be shared: 76 vectors of pure data with a SHA-256 over them, byte-identical to eBoot's copy (eBoot embeddedos-org#89). Each side runs its own verifier and prints the digest, so a change to one copy that does not reach the other shows up as two different digests. On its first run it failed here, on three vectors eBoot passes. ed25519_verify() had only ref10's partial check, `signature[63] & 224`. That rejects any S with a bit set above 2^252, but L = 2^252 + 27742317777372353535851937790883648493 so every S in [L, 2^253) passes it while being non-canonical. Adding L to a valid S lands squarely in that band, and (R, S) and (R, S + L) both verified — signature malleability. Anything treating a signature as a unique identifier (deduplication, replay caches, logging by signature hash) sees two distinct signatures over one message and one key. RFC 8032 5.1.7 requires rejecting S outside [0, L). eBoot's core/ed25519_verify.c has done this all along via s_is_canonical(); this side did not. Added sc_is_canonical(), compared most-significant byte first against a public constant — S is public, so this leaks nothing. Verified: contract vectors, before the fix -> 3 of 76 wrong (all three S+L) contract vectors, after -> 76/76, 3 accepted, 73 refused test_crypto_ed25519_loworder -> 5/5, unchanged ctest -> 35/35 passed digest 1059febedae2b3e3dfaa1ef5b419fb37ed7f0d53b377a3250b53b95a546282a2 identical to eBoot embeddedos-org#89's copy Stacked on embeddedos-org#116; eos master does not build without it.
tests/vectors/ed25519_contract_vectors.h is 54 KB of generated data, and the identical file was checked in to BOTH eos and eBoot, along with the generator that produces it -- 61 KB duplicated verbatim across two repositories and kept in step by hand. The generator is pure stdlib and deterministic, so each repo can produce the header into its own build tree instead. tools/gen_ed25519_contract_vectors.py grows an -o/--output flag (stdout stays the default, so the regeneration command in its docstring still works), and tests/CMakeLists.txt drives it from add_custom_command. Only the 6.8 KB generator is now carried twice, and it is source rather than generated output. Verified: the generated header is byte-identical to the file it replaces; both repos still report digest 1059febedae2b3e3dfaa1ef5b419fb37ed7f0d53b377a3250b53b95a546282a2, which is the thing that actually holds the two verifiers to one contract. Suites pass (eos 35/35, eBoot 22/22). Breaking the generator fails the build rather than silently reusing a stale header. The cross-compile legs configure with tests OFF so find_package(Python3) is never reached there; confirmed by configuring under a cross toolchain.
…ry directly Answers the review on embeddedos-org#122. The malleability fix itself was found correct; these are the findings around it. Finding 1 (High) -- the digest was printed and never compared, so the shared contract could not fail. EOS_ED25519_CONTRACT_DIGEST came from the header this repo's own generator had just written, a hash taken over its own output, so editing the generator here and not in eBoot produced a self-consistent corpus with a new digest, a green test, and a divergence visible only to a human reading two CI logs. Now pinned as a literal in committed source, together with the vector count and accept count, which are generated for the same reason and were equally unfalsifiable. The eBoot twin (embeddedos-org#89) pins the identical value. Finding 4 (Medium) -- sc_is_canonical()'s accept side had no committed test. Through the public API the boundary is unreachable: S == L and a failed group equation are both simply 0, so an off-by-one refusing a valid S = L - 1 would satisfy every vector in the corpus and reject real signatures only in the field. It is a pure function of 32 bytes, so tests/test_ed25519_canonical_s.c tests it directly -- 0, 1, L-1, L, L+1, 2^252, 2^252+2^251 and all-ones -- with no signing key and no `cryptography` module. ed25519_verify.c is compiled a second time with EOS_ED25519_TEST_HOOKS to give the function external linkage; the shipped build is unchanged. This is the case the finding was about, reproduced. With `S == L` accepted: test_ed25519_canonical_s [FAIL] at the S = L case test_ed25519_contract [PASS] all 76 vectors The corpus cannot see it. The direct test can. Also corrected while writing it: 2^252 is BELOW L (L = 2^252 + 2.77e37), so it is in range. The value that is both non-canonical and passed by ref10's `signature[63] & 224` test is 2^252 + 2^251 -- 0x18 & 224 == 0. Both are now asserted, the second alongside the mask check that shows why the partial test misses it. Finding 2 (Medium) -- tests/vectors/ed25519_contract_vectors.h is now committed rather than generated. CMAKE_CURRENT_SOURCE_DIR preceded CMAKE_CURRENT_BINARY_DIR on the include path, so a stale source-tree copy would silently win over the generated one -- and the PR body's own re-derivation command wrote to exactly that path. Committing removes the shadow, makes the corpus auditable from a tag, and drops find_package(Python3 ... REQUIRED) from a pure-C suite. NOT fixed here, and stated rather than left implied: finding 3, the corpus omitting the y >= p (non-canonical public key) family. That is the class most likely to diverge between the two verifiers and it is worth adding -- but adding vectors changes the digest, which both repos now pin, so it has to land in eos and eBoot together in one coordinated change. That is the mechanism working as intended, and it is a follow-up rather than something to slip into this PR. Verified: ctest 41/41 PASS test_ed25519_contract 76 vectors, 3 accept, 73 refuse test_ed25519_canonical_s PASS rebased onto master; conflicts in tests/CMakeLists.txt and services/crypto/src/ed25519_verify.c resolved additively -- master's ed25519_public_key_is_usable() (embeddedos-org#120) and this branch's sc_is_canonical() both kept. Refs embeddedos-org#122
…agged Answers the second round on embeddedos-org#122. Both findings are defects in my previous commit. The digest pin did not cover the corpus. EOS_ED25519_CONTRACT_DIGEST is a #define inside the generated header; EOS_ED25519_CONTRACT_EXPECTED is a #define in the driver. The test compared two string literals and never hashed a vector, so editing a committed vector changed the data and left the value it was compared against untouched. Raised on the eBoot twin (embeddedos-org#89) and stated there to apply here too; it did. contract_digest() now recomputes SHA-256 over the vectors at run time in the generator's serialisation -- public_key[32] || signature[64] || message[message_len] || (uint8_t)expect_accept -- via eos_sha256_* from eos/crypto.h. It compares the result to the pinned literal and to the header's own claim, so a corpus and a header from different generator runs are caught. Verified with the probe from the eBoot review. Changing one byte of the order-1 identity public key: [FAIL] corpus digest changed expected 1059febe...282a2 got 55370deb...cae9 exit 1 That `got` value is byte-identical to the one eBoot's driver produces for the same edit, which is independent confirmation that the two committed corpora really are the same file. Worth recording because it nearly cost me the finding: the first probe run reported PASS. The build had not picked up the header change, so the binary was stale. `touch` on the driver and a rebuild gave the failure above. A probe that does not rebuild proves nothing -- check that the link step ran. CodeQL alert 405, "Static function sc_is_canonical is unreachable" -- caused by the EOS_ED25519_TEST_HOOKS macro I added, which gave the symbol a linkage that varied by build. Removed: the function is static again, and tests/test_ed25519_canonical_s.c includes the translation unit to reach it, which is the ordinary way to test a static and needs no conditional compilation in a crypto source file. eos_crypto is still linked for the ge_*/sha512 helpers the included unit calls -- a static archive contributes only objects needed for undefined symbols, and ed25519_verify.c.o is not one, because the test object already defines everything in it. Verified: ctest 41/41 PASS test_ed25519_canonical_s PASS, links with no duplicate symbols test_ed25519_contract 76 vectors, 3 accept, 73 refuse byte-flip probe FAILS (after a real rebuild) off-by-one probe, `S == L` accepted: test_ed25519_canonical_s FAILS test_ed25519_contract still exit 0 so the direct boundary test still discriminates where the corpus cannot. Refs embeddedos-org#122
…e comments Addresses srpatcha's review findings 1 and 3 on embeddedos-org#122. Text and comments only; no behaviour change, and the 76 vectors and their digest are untouched. Finding 1 -- the body claimed the corpus was byte-identical to eBoot#89's copy and it was not: the two generators had drifted in their docstrings and in the header comment they emit, so the files differed by three comment lines while all 76 vectors and the digest matched. Adopted eBoot#89's wording verbatim -- it is the more accurate description, since each side recomputes the digest and compares it to a pinned literal rather than merely printing it -- and regenerated. Both repos' tools/gen_ed25519_contract_vectors.py and tests/vectors/ed25519_contract_vectors.h are now byte-identical: sha256 489b1fc0662187841c58e12c650ca1a25a974e4fafe4d4318f4fad62b7e4b5cf which is the value the review measured for eBoot's copy, so the spot-check the finding describes -- hash both files -- now passes. Finding 3 -- three comment blocks described an approach that was abandoned, two of them contradicting the code beside them: (a) ed25519_verify.c opened "Linked, not static, when the test hook is on" six lines above "It stays static", and the function is static. (b) tests/CMakeLists.txt described compiling ed25519_verify.c a second time with EOS_ED25519_TEST_HOOKS. That macro occurred exactly once in the tree -- in that sentence. The next line already describes what the test really does (#include the translation unit). (c) the paragraph saying the vector table "is produced into the build tree rather than committed" sat directly above one saying it is committed. It is committed; the generate-at-build-time paragraph was mine, left behind when that approach was reverted, and it is the half that was false. In each case the surviving text already explained the real design, so this is subtraction. Verified: cmake+ctest 41/41 (the count finding 4 asks for, not the 35/35 still in the body), test_ed25519_contract reports digest 1059feb... over 76 vectors with 3 accepted and 0 wrong, and test_ed25519_canonical_s still passes.
fedc7e2 to
68c39f1
Compare
srpatcha
left a comment
There was a problem hiding this comment.
Review — eos#122 "fix(ed25519): reject a non-canonical S, and share the contract with eBoot"
head: 68c39f1 author: Kartikey1306 ci: fail (2 of 26 — external cause, see finding 5)
Verdict: The fix is correct and I verified it rather than reading it. sc_is_canonical is a correct big-endian-first lexicographic S < L comparison; its L[32] is the standard little-endian encoding of 2^252 + 27742317777372353535851937790883648493; and the corpus does what the body says it does. Independently reimplementing RFC 8032 verification plus eos's two extra rules (canonical S, prime-order public key) and running all 76 committed vectors: 0 of 76 disagree with expect_accept, and with the canonicality check removed exactly 3 disagree — the three *_noncanonical_S vectors — which reproduces the body's "3 of 76 wrong before the fix" claim precisely. Recomputing SHA-256 over the committed vectors in the generator's serialisation order yields 1059febedae2b3e3dfaa1ef5b419fb37ed7f0d53b377a3250b53b95a546282a2, byte-for-byte the pinned literal, over 76 vectors with 3 accepts. The one substantive problem is that the fix silently disarmed three of the corpus's own negative vectors.
Findings
| # | Severity | File:line | Finding | Recommended fix |
|---|---|---|---|---|
| 1 | Medium | tests/vectors/ed25519_contract_vectors.h (rfc8032_test{1,2,3}_*_tampered_S) |
The three "one bit flipped in S" vectors no longer test what they document. Each is signature[63] ^= 0x10 — byte 63, bit 4, deliberately chosen to slip past ref10's signature[63] & 224 and reach the verification equation, which is what they tested before this PR. Adding 2^252 to an S already close to L puts all three at S >= L (I checked each: S_tampered < L is false for all three), so they are now short-circuited by the new range gate, on the identical code path as the *_noncanonical_S vectors. Net effect: after this fix the corpus contains no negative vector with a canonical-but-wrong S, so nothing on the reject side exercises the equation's dependence on S at all — it is pinned only by the three positives. The corpus documents six S-side negatives and effectively has three, and the count/accept-count pins in test_ed25519_contract.c cannot notice, because the vector count did not change. |
Regenerate with the S flip in a low byte — signature[32] ^= 0x01 — which keeps S < L, reaches the equation, and restores the pre-fix coverage. Then re-pin the digest in both repos. Worth doing in the same change as the fix, since the fix is what disarmed them. |
| 2 | Low | tests/CMakeLists.txt:100-112 |
test_ed25519_canonical_s #includes ed25519_verify.c and links eos_crypto, whose archive contains ed25519_verify.c.o. The CMake comment's reasoning is correct today — I listed eos_crypto's members and grepped its sources: nothing inside the archive references ed25519_verify, ed25519_public_key_is_usable or ed25519_key_has_prime_order, and the only caller, services/pkg/eos_pkg.c, is in a different library — so the linker has no undefined symbol that would pull that object in. But the property is incidental, not structural: the first time an object inside eos_crypto calls ed25519_verify, or this target grows a link to eos_eapp, it fails with a duplicate-symbol error whose cause is three files away. Separately, the TU is now compiled twice under different warning settings — -Wall -Wextra -Wpedantic is PRIVATE to eos_crypto, so the test's copy of ed25519_verify.c is not warning-checked. |
List what the included TU actually needs on the test target — ed25519_ge.c ed25519_sc.c ed25519_fe.c ed25519_sha512.c — instead of linking the whole archive, and give the target the same three warning options. That makes "does not pull in a second ed25519_verify" true by construction. |
| 3 | Low | services/crypto/src/ed25519_verify.c (sc_is_canonical's L[32]) and tests/test_ed25519_canonical_s.c:39-44 |
L is transcribed twice. Both copies are correct (verified against the RFC 8032 value), but a test that compares a function against its own re-transcription of the same constant cannot catch a wrong constant: edit both to the same wrong value and every check still passes. The corpus is the only thing that would notice, and only partially. |
Define L once in a non-installed internal header under services/crypto/src/ and include it from both, so there is one thing to get right. |
| 4 | Low | PR body, opening line | The body's instruction misdirects a reviewer: "Stacked on #116 (eos master does not build without it). Review the last commit." Against origin/master at feee272 this branch is a clean 6-file diff and contains nothing from #116. What it is stacked on is 13a8ffa "fix(build): restore the low-order Ed25519 suite that #93 removed" — which is also the base commit of eos#127, so the test_crypto_ed25519_loworder hunk in tests/CMakeLists.txt here is the same hunk in #119 and #127 (and, as a stray blank line at the same anchor, #126). Whichever lands second conflicts. eos#119's body sets out the merge order; this one says nothing. |
Correct the opening note, and either state the merge order (#127 first, then rebase and drop the hunk) or rebase now so this PR carries only its own five files. |
| 5 | Low | CI | Cross-compile ARM64 kernel and the Full-stack integration summary that depends on it are red on this head, and the cause is not in this diff. The failing step is Build eBoot (ARM64 qemu_arm64 board); the errors are in the eBoot checkout — eBoot/include/eos_image.h:135 and :142 static-assert on offsetof(eos_image_header_t, reserved) / sizeof(…->reserved) and the struct has no reserved member (run 33844971108). The workflow checks eBoot out at a floating master. eos#127, #129 and #135 share this merge base and pass those jobs, having run before the drift. eos#135 pins the eBoot and ebuild checkouts and is the fix. |
Nothing to do here beyond rebasing after #135. Do not read the red X as a verdict on this diff. |
Architecture conformance
Conforms. §21: services/crypto is Tier-1 eos; nothing moved and the split policy (§21.1) is not engaged. §5.1: no new dependency in either direction — the change is one static function plus test data, and the new test targets depend downward on eos_crypto. §14.1 "do not invent cryptographic primitives": satisfied — this adds the range check RFC 8032 §5.1.7 already specifies to an existing ref10-derived verifier, and does not introduce arithmetic of its own. Not constant-time by design, correctly justified in the comment: S is public, and the early exit compares against a public constant.
On the shared-corpus idea itself: the design gap it answers is already recorded — .ai/autoreview/proposals/2026-09.md, "A cryptographic primitive with two implementations has no owner and no cross-check" (2026-09-02, triggered by eBoot#89/#86), which proposes exactly the byte-identical-data-plus-asserted-digest pattern and names this PR. This head satisfies the eos half of that proposed rule: the corpus is committed rather than generated at build time, the digest is recomputed over the vector bytes at run time, and it is compared against a literal in committed source — which is more than the proposal's note says eBoot#89 did as submitted. So no duplicate proposal is filed for it. One thing the proposal's own mechanism does not cover is appended as an addendum (below).
Proposed changes
- Re-flip the three
_tampered_Svectors into the canonical band (signature[32] ^= 0x01), regenerate, re-pin the digest in eos and eBoot in the same pair of PRs. This is the only finding that changes what is tested. - Replace
target_link_libraries(test_ed25519_canonical_s PRIVATE eos_crypto)with the four helper sources, and add the warning options. - Hoist
L[32]into one internal header. - Fix the body's stacking note and state the merge order.
None of these interact with the verifier change itself, which should land as-is.
Not checked
- Nothing was compiled. No C compiler ran,
ctestwas not invoked, and the35/35and5/5figures are neither confirmed nor disputed. My verification is an independent Python reimplementation of RFC 8032 verification plus eos's two extra rules, run over the committed vector bytes — it confirms the corpus and the specification, and it confirms thatsc_is_canonical's logic and constant are right by inspection, but it does not prove this repo's C code produces those answers. Labelled Verified only for the digest, the vector expectations and the constant; Observed for the C. - The claim that the corpus is byte-identical to eBoot#89's copy is unverified — the eBoot side was not read in this run, and it is the half that makes the whole mechanism work. Nothing here can detect divergence (see the addendum).
tools/gen_ed25519_contract_vectors.pywas not executed; the corpus was checked as data, not re-derived through the generator, so a generator/committed-data mismatch would show up only as the digest failing, which it does not.ed25519_verifystill dereferencessignature[63]before any NULL check onsignature,public_keyormessage. Pre-existing, untouched by this diff, not assessed.- The three positive RFC 8032 vectors are the only accept cases; the over-rejection question near
S = L - 1is covered bytest_ed25519_canonical_sat the unit level and by the author's uncommitted 200-signature run, which I cannot verify. - No fuzz coverage for the verifier (
tests/fuzz/), and.ai/security.mdasks for it on externally reachable parsers. Pre-existing gap, not closed here.
Automated architecture review of 68c39f162cf8 — scheduled, model claude-opus-5, checked against the EmbeddedOS Master Design v2.0. Advisory only: this reviewer never approves, requests changes, or merges. Reply here to discuss or push back — a wrong finding is a bug worth reporting.
…cs is passed twice (#126) * fix(build): restore the low-order Ed25519 suite that #93 removed `CI — eos` and `Python Unit Tests` are red on master (769191a), and both fail for one reason: these suites exist in tests/ but no add_executable() in tests/CMakeLists.txt builds them, so they never run: ['test_crypto_ed25519_loworder.c'] 3aa8644 (#93, "remove duplicate test targets from tests/CMakeLists.txt") removed all four lines that build and register test_crypto_ed25519_loworder. It was not a duplicate — there was exactly one registration before that commit, and it took it. What stopped running is the suite that guards #101: Ed25519 rejecting low-order public keys. A low-order key makes every term of the verification equation collapse to the identity regardless of the message, so a signature of all zeros verifies against any content at all. The fix is still in services/crypto/src/ed25519_verify.c and still correct; nothing has been checking it since #93 merged, and nothing would have noticed if a later change had removed it too. Restored verbatim from 3aa8644^, in its original position before test_crypto_sha512. No other file changes. The guard that caught this is test_cmake_test_registration.py, added recently. It did exactly its job — this is the first thing it found. Verified: test_cmake_test_registration.py -> 5 passed (1 failed on master) pytest tests/ -> 15 passed (14 passed 1 failed on master) test_crypto_ed25519_loworder -> 5/5 tests passed ctest -> 39/39 passed (master builds 38) * fix(toolchain): stop passing nosys.specs twice on a Cortex-M4 link arm-cortex-m4.cmake sets -specs=nosys.specs in CMAKE_C_FLAGS_INIT and again in CMAKE_EXE_LINKER_FLAGS_INIT. CMake links through the compiler driver, so both reach the link line and the specs file is read twice. A specs file may only be read once -- the second read tries to re-rename a spec it has already renamed: arm-none-eabi-gcc: fatal error: nosys.specs: attempt to rename spec 'link_gcc_c_sequence' to already defined spec 'nosys_link_gcc_c_sequence' It has been latent because CMAKE_TRY_COMPILE_TARGET_TYPE is STATIC_LIBRARY and nothing in the cross build links an executable, so the duplicate never reached a link line. It surfaced the moment one did. The flag belongs on the link line only; arm-none-eabi-stm32f4.cmake already keeps it there and nowhere else. This matches it. The compile line does not need it: nosys.specs supplies the syscall stubs the linker wants. Verified with arm-none-eabi-gcc rather than by reading. The mechanism, using a specs file this toolchain does ship: once: arm-none-eabi-gcc ... m.o -specs=dup.specs -o a.elf -> links twice: arm-none-eabi-gcc -specs=dup.specs m.o -specs=dup.specs -> fatal error: attempt to rename spec 'link_gcc_c_sequence' to already defined spec 'dup_link_gcc_c_sequence' the same error CI reports, from the same cause. And the generated build: before, every compile command carried -specs=nosys.specs; after, none does and the exe link flags still carry it once. The full cross build could not be run here -- the Homebrew arm-none-eabi-gcc ships no newlib, so nosys.specs and stdint.h are both absent. Configure succeeds with this change and fails without it on that toolchain, which is the part it governs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(toolchain): guard the duplicate-specs class, and align the third ARM file Answers the review on #126. Finding 1 (Medium) -- the head carried an undisclosed second change, the test_crypto_ed25519_loworder registration, which contradicted this PR's own 2026-09-02 comment ("this PR changes one file ... and does not touch tests/"). It was also the fourth open copy of that registration, at a different position from #127's. Dropped: the branch is rebased to the toolchain change alone, and #127 is the one to take for the registration -- it restores the four lines to their original position, keeps the crypto suites grouped, and is the position #122 is already written against. Finding 2 (Medium) -- nothing would catch this coming back. Every ARM toolchain here sets CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY and no cross build links an executable, so `Cross-compile ARM Cortex-M4` passed identically with the duplicate present and absent; it passed on the unfixed commit. The defect surfaced by accident when #118 briefly put cmd/eos into the cross build, and #118 has since gated that to host builds -- removing the accident too. Adds tests/unit/test_toolchain_specs.py, modelled on test_cmake_test_registration.py: parse every toolchains/*.cmake, extract each -specs=<file>, and fail if one appears in more than one variable that reaches a link line (CMake links through the compiler driver, so C/CXX flags do). A second test asserts specs are declared on the linker flags rather than the compiler flags, which is the arrangement that turns into a duplicate the moment someone adds the linker entry that looks missing. No ARM toolchain needed; runs in the existing pytest job. Finding 3 (Low) -- arm-none-eabi-r5.cmake put --specs=nosys.specs in CMAKE_C_FLAGS_INIT with nothing in CMAKE_EXE_LINKER_FLAGS_INIT. Not broken today, and one added linker -specs= away from the same failure. Moved, so all three ARM bare-metal toolchains state it the same way and the new guard is green across all of them. Verified: pytest tests/unit/test_toolchain_specs.py 2 passed pytest tests/ all pass discrimination: reintroducing the duplicate in arm-cortex-m4.cmake gives arm-cortex-m4.cmake: -specs=nosys.specs is set in CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT, so it reaches the link line more than once on both tests, so the guard catches the original defect rather than restating the fix. No ARM toolchain on this machine, so the cross link itself is NOT verified here; that remains CI's to show. Refs #126 * test(toolchains): see the spec wherever it is written, and drop the borrowed hunk Addresses findings 1-4 of the review on 126bd0c. Finding 4 first, because it is the one that was making the branch look better than it is. The test_crypto_ed25519_loworder registration was carried here from #127. It made Run Python tests green on a branch that has nothing to do with it, contradicted this PR's own 2026-09-02 comment, and sat at a different anchor to #127's, so it would have conflicted. Dropped. That check is red again until #127 lands, which is the honest state -- folding a master repair into a toolchain PR to get a green tick is how a red master becomes everyone's problem to route around. Finding 1: the parser recognised one CMake idiom, set(VAR "literal"). It could not see string(APPEND ...), an unquoted set(), or -- the one that matters here -- a spec arriving through ${CPU_FLAGS}, which is how both Cortex-M4 toolchains build their flag strings. Putting -specs= there would have reached both CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT while every check passed. The file is now walked in order with a variable table, ${...} resolved against what is in force at that point, and string(APPEND ...) applied. Two tests pin those two bypasses. Finding 3: CMAKE_ASM_FLAGS_INIT added to LINK_REACHING. CMake picks a target's linker language from its sources, so an executable built only from .S files links with the ASM flags. Both Cortex-M4 files set it from ${CPU_FLAGS}. The current tree still passes; no toolchain names a spec there today. Finding 2: the .yaml descriptors are scanned too. They are the second authority on these flags -- toolchains/src/toolchain.c parses them into EosToolchain.cflags/ldflags, a separate consumer from CMake -- and all five already keep specs in ldflags, which is a good argument for this change and a better one for holding both descriptions to one rule. Found while verifying rather than assuming: my first regex required the closing paren on the same line, so arm-none-eabi-stm32f4.cmake's three-line set(CMAKE_EXE_LINKER_FLAGS_INIT ...) was invisible and every check passed vacuously for that file. That is this test's own failure mode one level up. Fixed, and pinned by test_a_multi_line_set_is_still_seen and test_every_arm_toolchain_is_actually_parsed -- the latter asserts the parser finds specs in all three ARM files, so a parser that silently sees nothing can no longer pass. Verified: the parser reports arm-cortex-m4 [nosys, nano], arm-none-eabi-r5 [nosys], arm-none-eabi-stm32f4 [nano, nosys], all on CMAKE_EXE_LINKER_FLAGS_INIT. Mutations caught: a spec hidden in CPU_FLAGS, and the original duplicate re-introduced on CMAKE_C_FLAGS_INIT. 7 toolchain tests pass, ctest 38/38. * test(toolchains): drop the blank line the removed hunk left behind Residue from 0957ac4, which dropped the borrowed test_crypto_ed25519_loworder registration and left the blank line that had separated it from the block below. It is the only remaining change this PR makes to tests/CMakeLists.txt, it changes nothing, and it sits at the exact anchor where #118, #119 and #127 each add the real registration block -- so it would have conflicted with whichever of those lands first, for no reason at all. tests/CMakeLists.txt is now untouched by this branch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(build): take the loworder registration from #127, not a copy of it Restacked onto #127. The previous head dropped the registration hunk entirely, per finding 4, which left Run Python tests red by design until #127 landed. Stacking gets the same result without a duplicate: the hunk comes from #127, this branch adds none of its own, and the diff against #127 is toolchain-only. Verified locally before pushing: pytest 22 passed (including test_every_c_suite_is_built_or_listed, the check that was failing), cmake configure and build clean, ctest 39/39 with test_crypto_ed25519_loworder among them. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ARM file Answers the review on embeddedos-org#126. Finding 1 (Medium) -- the head carried an undisclosed second change, the test_crypto_ed25519_loworder registration, which contradicted this PR's own 2026-09-02 comment ("this PR changes one file ... and does not touch tests/"). It was also the fourth open copy of that registration, at a different position from embeddedos-org#127's. Dropped: the branch is rebased to the toolchain change alone, and embeddedos-org#127 is the one to take for the registration -- it restores the four lines to their original position, keeps the crypto suites grouped, and is the position embeddedos-org#122 is already written against. Finding 2 (Medium) -- nothing would catch this coming back. Every ARM toolchain here sets CMAKE_TRY_COMPILE_TARGET_TYPE to STATIC_LIBRARY and no cross build links an executable, so `Cross-compile ARM Cortex-M4` passed identically with the duplicate present and absent; it passed on the unfixed commit. The defect surfaced by accident when embeddedos-org#118 briefly put cmd/eos into the cross build, and embeddedos-org#118 has since gated that to host builds -- removing the accident too. Adds tests/unit/test_toolchain_specs.py, modelled on test_cmake_test_registration.py: parse every toolchains/*.cmake, extract each -specs=<file>, and fail if one appears in more than one variable that reaches a link line (CMake links through the compiler driver, so C/CXX flags do). A second test asserts specs are declared on the linker flags rather than the compiler flags, which is the arrangement that turns into a duplicate the moment someone adds the linker entry that looks missing. No ARM toolchain needed; runs in the existing pytest job. Finding 3 (Low) -- arm-none-eabi-r5.cmake put --specs=nosys.specs in CMAKE_C_FLAGS_INIT with nothing in CMAKE_EXE_LINKER_FLAGS_INIT. Not broken today, and one added linker -specs= away from the same failure. Moved, so all three ARM bare-metal toolchains state it the same way and the new guard is green across all of them. Verified: pytest tests/unit/test_toolchain_specs.py 2 passed pytest tests/ all pass discrimination: reintroducing the duplicate in arm-cortex-m4.cmake gives arm-cortex-m4.cmake: -specs=nosys.specs is set in CMAKE_C_FLAGS_INIT and CMAKE_EXE_LINKER_FLAGS_INIT, so it reaches the link line more than once on both tests, so the guard catches the original defect rather than restating the fix. No ARM toolchain on this machine, so the cross link itself is NOT verified here; that remains CI's to show. Refs embeddedos-org#126
…he key a device needs Review follow-up on embeddedos-org#152. This PR opted the two CI builds of eBoot into the development key ahead of eBoot#122's gate; the review measured that eBoot#122 has since moved to exempt only Debug and refuse an unset CMAKE_BUILD_TYPE, so the eight documented `cmake -B ... -DEBLDR_BOARD=<real board>` commands in this repository -- none of which set a build type -- stop working the day it merges. CI and the docs break for one reason; only CI had been fixed. Seven development snippets (GETTING_STARTED.md:249, integration-guide.md:85 and :194, adding-hardware.md:134, three-way-alignment.md:209, DEPLOYMENT.md:182, ch17-eboot.md:92) now pass -DCMAKE_BUILD_TYPE=Debug: a reader following the guide is doing a development build, and Debug is what the gate exempts. The one deployment snippet (DEPLOYMENT.md:133, "flash eBoot first") says what a device image actually needs instead -- -DEBLDR_PRODUCTION_KEY=<64 hex characters: your Ed25519 public key> -- since a Debug build is not what goes on a device. DEPLOYMENT.md:177 uses EBLDR_BOARD=none and is exempt as it was. Measured, not read, against both trees with configure-only runs: GETTING_STARTED.md:249 verbatim eBoot master 682d005 rc=0 eBoot#122 1cd1265 rc=1 ... + -DCMAKE_BUILD_TYPE=Debug master rc=0 embeddedos-org#122 rc=0 DEPLOYMENT.md:133 + a real Ed25519 public key (openssl genpkey) master rc=0 embeddedos-org#122 rc=0, "Trust anchor: production key from EBLDR_PRODUCTION_KEY" so every edit is valid on today's master and this can land before eBoot#122. A 64-character dummy is refused by cmake/ProductionKey.cmake ("not a usable Ed25519 public key"), which is why the placeholder says what the key is. The review's own sweep, re-run: no `cmake -B ... -DEBLDR_BOARD=<board>` in any .md is left without a build type or a key within its command. pytest tests/unit 43 passed. three-way-alignment.md:209 is the same line in embeddedos-org#151's rewrite of that file, so whichever of embeddedos-org#151/embeddedos-org#152 lands second rebases without conflict.
Two things, and the second found the first.
The shared contract
eos and eBoot each carry their own Ed25519 verifier. Different code, opposite return conventions —
ed25519_verify()returns1to accept, eBoot'seos_ed25519_verify()returnsEOS_OK— doing the same job on the same wire format. When only eos rejected low-order public keys, nothing in either repo could notice: there is no shared build, and the two are far too different for a source diff to say anything useful. That divergence is why eBoot #73 survived over there after this side had already fixed it.The implementations can't be shared. The data can.
tests/vectors/ed25519_contract_vectors.his 76 vectors with a SHA-256 over them, byte-identical to eBoot #89's copy:S + L, non-canonical per RFC 8032 §5.1.7What it caught on its first run
Three vectors failed here that eBoot passes.
ed25519_verify()had only ref10's partial check:That rejects any S with a bit set above 2^252. But
so every S in
[L, 2^253)passes that check while being non-canonical. Adding L to a valid S lands squarely in that band, and(R, S)and(R, S + L)both verified.That is signature malleability. Anything that treats a signature as a unique identifier — deduplication, replay caches, logging or indexing by signature hash — sees two distinct signatures over one message and one key.
RFC 8032 §5.1.7 requires rejecting S outside
[0, L). eBoot'score/ed25519_verify.chas done this all along vias_is_canonical(); this side never did. Addedsc_is_canonical(), comparing most-significant byte first against a public constant — S is public, so the early exit leaks nothing.This is exactly the failure mode the corpus exists for: not a defect in either implementation's design, but one side quietly having a check the other lacks, with nothing able to tell you.
Validation
The corpus is generated rather than transcribed, so it can be re-derived and re-checked:
The positive vectors are load-bearing
A verifier that refused everything would satisfy all 73 negative vectors. The three RFC 8032 signatures are the only thing separating "hardened" from "broken", so the test fails explicitly if the corpus ever loses its accept cases — not merely if a vector misbehaves.