Skip to content

feat(net): implement the eNet socket API on POSIX sockets - #89

Merged
srpatcha merged 7 commits into
masterfrom
feat/enet-posix-sockets
Sep 1, 2026
Merged

feat(net): implement the eNet socket API on POSIX sockets#89
srpatcha merged 7 commits into
masterfrom
feat/enet-posix-sockets

Conversation

@srpatcha

Copy link
Copy Markdown
Member

ADR-014 point 3: "On the Compute profile, where a host OS is present, the same API maps to POSIX sockets." This is that mapping.

Before this, every socket function in eNet was a stub:

int eos_net_connect(eos_socket_t sock, const eos_net_addr_t *addr)
{
    (void)sock; (void)addr;
    return -1;
}

eos_net_socket() returned an incrementing integer naming nothing. Anything built on eNet could not be exercised on any target — including the host, which is where EoSim runs and where the org's own ecosystem gate would test it.

[PASS] net tcp round trip
[PASS] net resolve localhost
[PASS] net connect to a closed port fails

The fd is the handle

eos_socket_t is an int, EOS_SOCKET_INVALID is -1, and that is exactly what socket(2) returns on failure. No descriptor table to keep in step, and no way for the two to drift.

Three things not obvious from the API shape

  • eos_net_addr_t documents ip as network byte order, so it's copied into sin_addr rather than passed through htonl — which would swap it twice.
  • send uses MSG_NOSIGNAL. Writing to a peer that has gone away raises SIGPIPE by default, which kills the process instead of returning an error the caller can act on.
  • A zero timeout means "block forever" to SO_RCVTIMEO — the opposite of what a caller passing 0 expects. The option is only set when non-zero. A timed-out recv returns 0, not -1: no bytes arrived, nothing failed.

The stubs stay

Behind #ifndef EOS_NET_POSIX, not deleted. On a target with no IP stack, failing is the honest answer — and that is the Planned state ADR-014 records for Nano and Edge until lwIP lands.

A gating trap worth knowing about

Gated on CMAKE_CROSSCOMPILING alone, deliberately not on the EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING form used a few lines above for hal_linux.c.

EOS_PLATFORM defaults to "linux" and a cross toolchain file does not change it, so that condition is true even for a Cortex-M build. I used it first and the ARM build failed on <netdb.h>.

hal_linux.c survives the same condition only because it uses portable headers — but it is being compiled into the Cortex-M build today. Worth a separate look; I've left it alone.

Scope

The socket layer. ADR-014 point 5 keeps MQTT, CoAP and HTTP out as separate adoption decisions on top of a working IP stack; they remain stubs. This does not make eNet Implemented — lwIP is still the decision for the profiles with no host OS to borrow sockets from.

Verification

host ctest 28/28; test_net 16 → 19 cases
bare-metal Cortex-M net.c compiles with EOS_ENABLE_NET=1, defines eos_net_connect exactly once — as does the host build
ARM cross-build clean, 0 errors

🤖 Generated with Claude Code

srpatcha and others added 6 commits August 28, 2026 23:35
`master` does not configure, and once it does it does not build, and once it
builds one suite fails. Each break is the same failure: a PR verified green on
its own branch, squash-merged onto a base that had moved, and nothing
re-verified the result.

1. tests/CMakeLists.txt registered test_crypto_aes and test_crypto_sha512
   twice (#59 landing beside the block that already had them), so CMake
   refused to configure at all:

       add_test given test NAME "test_crypto_aes" which already exists

2. eos_task_set_current_internal was defined twice in task.c and declared
   twice in kernel_internal.h, with two different behaviours — one clears
   g_current and marks the task RUNNING, the other silently returns. Kept the
   first, which is what the header documents ("Handles >= EOS_MAX_TASKS clear
   it") and what the mutex tests rely on; folded the second declaration's
   comment into the surviving one, since it records why the function exists.

3. sync.c would not compile: g_blocked_on, pi_propagate and task_valid were
   used and defined nowhere, alongside a reference to a struct member
   original_prio that mtx_t does not have.

   #67 and #55 implement incompatible designs for the same feature. #67
   recomputes effective priority from

       effective = min(base_priority, priority of every waiter on every
                       mutex the task holds)

   on every change; #55 saves and restores a value around each lock. Merging
   #55 after #67 deleted #67's implementation while leaving its call sites,
   so neither design was left intact.

   Restored #67's. It is the stronger of the two — save/restore is wrong when
   a task holds two mutexes and releases one, and wrong again when a task is
   boosted while already holding a mutex — and it subsumes what #55 fixes:
   recomputing on timeout is one of the cases it recomputes on, and it
   already rejects a full waiter table rather than blocking a task that was
   never enqueued.

4. #74's tests were merged without its parser change. Six assertions in
   test_config failed because a "- name:" item arriving while section was
   SEC_PKG_DEPS fell into the dependency branch, so the package after a deps
   list was swallowed as a dependency of the previous one. Restored the
   SEC_PKG_DEPS arm of the outer condition, nested the dependency handling as
   the else of the name check, and added the deps transition under
   SEC_PKG_BUILD.

    before   cmake: configure fails
    after    build clean, ctest 28/28

Also re-checked the package-overflow guard that arrived via #61/#65, since it
landed in a different shape than #78 proposed: AddressSanitizer is clean on a
129-package config that previously overflowed, and the 129th entry is refused
with a message rather than silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eos does not build for the target it exists to build for. From a clean
checkout of master, with the toolchain the CI job installs:

    $ cmake -B barm -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-cortex-m4.cmake
    $ cmake --build barm
    core/src/log.c:113:9: error: implicit declaration of function 'clock_gettime'
    core/src/log.c:113:23: error: 'CLOCK_MONOTONIC' undeclared

get_timestamp_ms() had two branches, _WIN32 and "else, assume POSIX". Bare-
metal newlib is neither: arm-none-eabi declares neither clock_gettime nor
CLOCK_MONOTONIC, so the POSIX branch cannot compile for any Cortex-M target.
The macro is the precise test — where it is absent, so is the function.

There is a timebase on a target, eos_get_tick_ms() in the HAL, but core/ sits
below hal/ and must not depend upward, so log entries carry a zero timestamp
on a freestanding build until a port supplies one. Ordering within the ring
buffer is unaffected; only absolute time is missing.

Also fixed the format specifier one line down. uint32_t is `unsigned int` on
x86-64 and `unsigned long` on 32-bit ARM, so the literal %u was wrong on
exactly the target this build is for. It is PRIu32 now, which is right on
both.

Why CI did not catch it. The ARM job points CMAKE_TOOLCHAIN_FILE at
cmake/arm-cortex-m4.cmake; the file is at toolchains/. That fails loudly --
"Could not find toolchain file" -- so the job has been red rather than
silently passing, but it dies at configure and never reaches the compile step
where this bug lives.

The path is not new breakage: #75 fixed it, #79 and #51 carried the fix, and
#59 reverted it. That is the same stale-base merge pattern as the other three
repairs on this branch.

    before   ARM: configure fails on a missing toolchain file; with the real
             path, 2 compile errors in core/src/log.c
    after    ARM: 70/70 targets built, artifacts report `architecture:
             armv7e-m`; host: 28/28 ctest, unchanged

Kernel and services together measure 15.8 KB flash and 26 KB RAM on
Cortex-M4, which is the first real footprint number this repo has produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every quick start in the tree opened with

    git clone https://github.com/anthropic/EoS.git

That repository does not exist — `gh api repos/anthropic/EoS` returns 404. It
is step one of the documented onboarding path in seven files: GETTING_STARTED,
the host/stm32/rpi4/nrf52 quickstarts, the integration guide and the STM32
tutorial. Any external developer following the docs stopped there.

The §30 12-month framework lists "tested quick start" among the 0–3 month
Foundation deliverables. It was not tested, and could not have been.

Two further defects behind the URL, both of which only surface once the clone
succeeds:

- The docs then `cd EoS/eos/examples/blink-gpio`, but cloning eos.git creates
  a directory called `eos` with `examples/` at its root. `EoS/eos/...` belongs
  to a multi-repo workspace that the single clone command never produces. The
  single-repo quick starts now use the path the clone actually creates. The
  guides that genuinely describe the multi-repo workspace are left alone —
  their paths are right, and what they are missing is a step that sets the
  workspace up, which is a larger documentation question than this change.

- They pass -DCMAKE_TOOLCHAIN_FILE=toolchains/arm-none-eabi.cmake, which does
  not exist. The ARM toolchain files are arm-cortex-m4.cmake,
  arm-none-eabi-stm32f4.cmake and arm-none-eabi-r5.cmake. Pointed at
  arm-cortex-m4.cmake, which is the one the CI job and the Cortex-M reference
  class use.

Verified by running the host quick start verbatim against the published repo,
from an empty directory:

    git clone https://github.com/embeddedos-org/eos.git
    cd eos/examples/blink-gpio
    cmake -B build -DEOS_PRODUCT=iot
    cmake --build build
    ./build/blink-gpio
    [blink] Starting LED blink on pin 13
    [blink] LED ON

The example itself was always fine. Only the instructions were wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§25 requires every significant feature to carry a maturity state with the
evidence that state demands. No such record existed on master — grepping the
tree for Implemented / Experimental / Planned / Validated / Certified returned
nothing.

That is the failure §25 exists to prevent, and it is load-bearing here. Without
it, 84 board descriptors read as 84 supported boards, a 190-line facade reads
as a networking subsystem, and a set of signature routines that verify nothing
reads as secure boot.

A version of this file was written on the reconcile/tier-1 branch and never
merged. This is that work brought to master and re-measured, because several
of its rows are now wrong in both directions.

Six subsystems are recorded as Planned rather than Experimental, each citing
the reason:

- eNet. ADR-014 point 4 requires this correction in the PR that merges the ADR;
  the ADR merged and the correction did not. net/src/net.c is 190 lines with 20
  (void)param casts and eos_net_connect() is `return -1`. test_net passes
  because it tests the facade's argument validation.
- RSA/ECC signatures. eos_ecc_verify() accepted any 64-byte signature and
  eos_rsa_verify_sha256() compared only the trailing 32 bytes. Both refuse to
  run now, which is the correct behaviour and still not verification.
- The HAL host backend. hal_linux.c implements one, but
  eos_hal_linux_register() is never called and appears in no public header, so
  on a host build eos_hal_init() returns -1 and every HAL call silently
  no-ops.
- The extended HAL. hal_extended_stubs.c is 740 of the HAL's 3,185 lines and
  carries 144 (void)param casts.
- Boards. 84 descriptors, 71 of them named generic-*.

Two rows move the other way, because the evidence improved:

- ARM Cortex-M4/R5 goes from Planned to Experimental. The tree cross-compiles
  clean and objdump reports armv7e-m and armv7 on the artifacts. It has still
  never been executed, which the row says.
- ctest is 28/28, not the 21/21 the old file recorded.

RISC-V and Cortex-A are Unknown rather than Planned: the toolchain files exist,
the toolchains are not installed here, so nothing was built. NOT RUN is an
answer; a guess dressed as Planned is not.

No subsystem claims Implemented, Validated or Certified. Each of those needs
evidence nothing in the tree currently supports.

Not included: component-manifest.json, which also sits on reconcile/tier-1. It
describes an arch/ tier that does not exist on master, so bringing it forward
needs its own pass rather than riding along here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-014 point 3: "On the Compute profile, where a host OS is present, the same
API maps to POSIX sockets." This is that mapping.

Before this, every socket function in eNet was a stub. eos_net_connect() was

    int eos_net_connect(eos_socket_t sock, const eos_net_addr_t *addr)
    {
        (void)sock; (void)addr;
        return -1;
    }

and eos_net_socket() returned an incrementing integer naming nothing. Anything
built on eNet could not be exercised on any target — including the host, which
is where EoSim runs and where the org's own ecosystem gate would test it.

    [PASS] net tcp round trip
    [PASS] net resolve localhost
    [PASS] net connect to a closed port fails

The fd is the handle. eos_socket_t is an int, EOS_SOCKET_INVALID is -1, and
that is exactly what socket(2) returns on failure, so there is no descriptor
table to keep in step and no way for the two to drift.

Three details that are not obvious from the API shape:

- eos_net_addr_t documents ip as network byte order, so it is copied into
  sin_addr rather than passed through htonl, which would swap it twice.
- send uses MSG_NOSIGNAL. Writing to a peer that has gone away raises SIGPIPE
  by default, which kills the process instead of returning an error the caller
  can act on.
- A zero timeout means "block forever" to SO_RCVTIMEO, which is the opposite of
  what a caller passing 0 expects, so the option is only set when non-zero. A
  timed-out recv returns 0 rather than -1: no bytes arrived, nothing failed.

The freestanding stubs stay in net.c behind #ifndef EOS_NET_POSIX rather than
being deleted. On a target with no IP stack, failing is the honest answer, and
that is the Planned state ADR-014 records for the Nano and Edge profiles until
lwIP lands.

Gated on CMAKE_CROSSCOMPILING alone, deliberately not on the
`EOS_PLATFORM STREQUAL "linux" OR NOT CMAKE_CROSSCOMPILING` form used a few
lines above for hal_linux.c. EOS_PLATFORM defaults to "linux" and a cross
toolchain file does not change it, so that condition is true even for a
Cortex-M build — I used it first and the ARM build failed on <netdb.h>.
hal_linux.c survives the same condition only because it uses portable headers;
it is compiled into the Cortex-M build today, which is worth a separate look.

Scope is the socket layer. ADR-014 point 5 keeps MQTT, CoAP and HTTP out as
separate adoption decisions on top of a working IP stack; they remain stubs in
net.c. This does not make eNet Implemented — lwIP is still the decision for the
profiles with no host OS to borrow sockets from.

Verification: host ctest 28/28, with test_net going from 16 to 19 cases;
net.c compiles for bare-metal Cortex-M with EOS_ENABLE_NET=1 and defines
eos_net_connect exactly once, as does the host build; full ARM cross-build
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread kernel/src/task.c

memset(&g_tasks[slot], 0, sizeof(eos_task_t));
g_tasks[slot].id = (uint8_t)slot;
g_tasks[slot].name = name;
@srpatcha
srpatcha merged commit 3df89a8 into master Sep 1, 2026
24 of 27 checks passed
@srpatcha
srpatcha deleted the feat/enet-posix-sockets branch September 1, 2026 10:57
srpatcha added a commit that referenced this pull request Sep 3, 2026
tests/test_net.c calls assert() and never included <assert.h>:

    tests/test_net.c:212:5: error: implicit declaration of function 'assert'
    build errors: 4

master has not compiled since #89 merged. I wrote that PR, so this is mine.

Why it was not caught: the file relied on assert() arriving transitively
through another header on the branch it was developed on, and nothing built
the merge result. The repository has no required status check —

    $ gh api repos/embeddedos-org/eos/branches/master/protection
    {"checks": null, "reviews": 1, "sigs": false}

— which is the same gap tracked in #92, and this is now the third time it has
let a non-compiling master through.

With the include:

    0 build errors
    100% tests passed, 0 tests failed out of 34

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026
…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.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026
…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
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026
…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.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026
…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
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 3, 2026
…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
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 4, 2026
…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.
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 4, 2026
…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
Kartikey1306 added a commit to Kartikey1306/eos that referenced this pull request Sep 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants