Repository navigation
Conversation
Verify already fail-closed; sign still minted fake signatures in production. Register the existing test_crypto_failclosed suite so that cannot regress. Signed-off-by: João Morais <118842104+JoaoMorais03@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The test CMakeLists currently defines duplicate targets (breaking test configuration) and the RSA signing stub still risks unsafe length underflow/overflow in EOS_ALLOW_STUB_CRYPTO builds.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR hardens the stub RSA/ECC implementation in eos_crypto by making sign fail-closed (matching existing verify behavior) unless EOS_ALLOW_STUB_CRYPTO is explicitly enabled, and registers a regression test intended to prevent “fake signature” behavior from silently returning success in production builds.
Changes:
- Add
EOS_STUB_CRYPTO_REFUSE(...)guards toeos_rsa_sign_sha256()andeos_ecc_sign()so stub signing refuses to run withoutEOS_ALLOW_STUB_CRYPTO. - Register
test_crypto_failclosedin the test CMake list to exercise the fail-closed production path.
File summaries
| File | Description |
|---|---|
tests/CMakeLists.txt |
Registers test_crypto_failclosed (but currently contains duplicate AES/SHA512 targets that will prevent CMake from configuring tests). |
services/crypto/src/rsa_ecc_sha512.c |
Makes RSA/ECC signing match verify’s fail-closed behavior; small stub-safety/cleanup issues remain. |
Review details
Suppressed comments (1)
services/crypto/src/rsa_ecc_sha512.c:58
eos_rsa_sign_sha256()computeslenfromkey_bitsand then useslen - 35/len - 33later. If a caller sets an unexpectedly smallkey_bitsvalue (or an oversized one), this can underflow/overflow and cause out-of-bounds writes tosigin EOS_ALLOW_STUB_CRYPTO builds.
EOS_STUB_CRYPTO_REFUSE("eos_rsa_sign_sha256");
/* Stub: produce a deterministic fake signature for testing.
* Replace with real PKCS#1 v1.5 or PSS signing. */
size_t len = (size_t)(key->key_bits / 8);
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| add_executable(test_crypto_sha512 test_crypto_sha512.c) | ||
| target_link_libraries(test_crypto_sha512 PRIVATE eos_crypto) | ||
| add_test(NAME test_crypto_sha512 COMMAND test_crypto_sha512) | ||
|
|
||
| # --- test_crypto_failclosed: unimplemented RSA/ECC stubs refuse to run --- |
| EOS_STUB_CRYPTO_REFUSE("eos_ecc_sign"); | ||
| /* Stub: produce a fake 64-byte signature (r||s) */ | ||
| size_t len = 64; | ||
| if (hash_len < 32) len = 64; |
srpatcha
left a comment
There was a problem hiding this comment.
Verified — this closes a real asymmetry
Checked origin/master before reviewing. The guards are on verify only:
50:int eos_rsa_sign_sha256(...) ← unguarded
67:int eos_rsa_verify_sha256(...)
69: EOS_STUB_CRYPTO_REFUSE("eos_rsa_verify_sha256")
80:int eos_ecc_sign(...) ← unguarded
92:int eos_ecc_verify(...)
97: EOS_STUB_CRYPTO_REFUSE("eos_ecc_verify")
So a build that correctly refuses to verify with stub crypto would still happily sign with it. Your reasoning for why that matters is the right one: a stub that mints a PKCS#1-looking blob or a fake r||s lets a signing pipeline appear to work end to end. The artifacts it produces are not signed by anything, and the failure surfaces at some later point — a real verifier rejecting them, or worse, a stub verifier accepting them — a long way from the code that caused it.
Making sign and verify symmetric is the correct fix, and reusing EOS_ALLOW_STUB_CRYPTO rather than inventing a second opt-in keeps one switch for one concept.
The test is the durable part
test_crypto_failclosed deliberately does not define EOS_ALLOW_STUB_CRYPTO, so it exercises the production path rather than the test path. That is the distinction that makes it a guard: the existing crypto tests all opt in, so none of them would notice the refusal being removed.
The note to replace it with known-answer tests against published vectors when real verification lands is the right instruction to leave behind — a fail-closed test becomes actively misleading once the function works.
One merge-order note
This conflicts with #82, which repairs master. Both touch tests/CMakeLists.txt: your new test_crypto_failclosed registration sits inside the duplicated test_crypto_aes / test_crypto_sha512 block that #82 removes — the duplicate is why master currently fails to configure at all.
The resolution is to keep your test_crypto_failclosed lines and drop the duplicated aes/sha512 ones, which are already registered earlier in the file. I did exactly that locally and it is clean.
Verification
Merged onto origin/master + #82, resolved as above: configure OK, 0 build errors, ctest 29/29. Not verified against master alone, because master does not currently configure.
ed25519_verify() decoded the public key and never checked which subgroup it
was in. Ed25519 has eight low-order points, and for any of them every term
of the verification equation collapses regardless of the message, so a
signature of all zeros verifies against arbitrary content.
This was not hypothetical. eos_pkg.c:94 ships
static const uint8_t eos_pkg_public_key[32] = {0};
and the all-zero encoding is one of the eight. Measured against the built
library, before this change:
all-zero key: 2 of 256 arbitrary signatures ACCEPTED
Package installation authenticated nothing. The SHA-256 check two lines
above is no help — it compares a hash of the payload against a field in the
same attacker-supplied file, which detects corruption, not origin. See #98.
The check multiplies the key by L and requires the identity, rather than
comparing against a table of the eight encodings. Both are correct; this one
cannot be wrong in a way that silently rejects valid keys because a constant
was transcribed incorrectly.
Two things this took to get right, both found by testing rather than
reasoning:
The subgroup test alone is insufficient. The identity has order 1, which
divides L, so [L]identity = identity and it passes. The first version of
this check still accepted an identity key. It is now rejected explicitly,
which is why the helper has two parts.
The regression test's first version passed against the unfixed code. The
all-zero key is order 4, so with S = 0 the equation reduces to [k](-A) and
whether that lands on R depends on k mod 4 — on the message. The forgery
succeeds for roughly one message in four, and "untrusted firmware" happened
to be one of the three that do not verify. It looked like a regression test
and was not one. It now sweeps messages verified to be accepted by the
unfixed implementation.
unfixed test_all_zero_key_is_rejected [FAIL] at line 112
fixed 5/5 tests passed
The suite covers all 64 combinations of the eight low-order encodings as key
and as R, plus RFC 8032 vector 1 — without which a check that rejected every
key would also pass.
ctest 28/29 -> 29/29 passed, 0 build errors
This does not make package signing work. A correct verifier with an all-zero
trust anchor now rejects everything instead of accepting everything, which is
the safer failure and still not a functioning signature check. #98 covers
supplying a real key, and whether eos_pkg should refuse to run at all while
its anchor is unset — the shape #84 uses for stub RSA/ECC.
Refs #98
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(crypto): reject Ed25519 public keys outside the prime-order subgroup
ed25519_verify() decoded the public key and never checked which subgroup it
was in. Ed25519 has eight low-order points, and for any of them every term
of the verification equation collapses regardless of the message, so a
signature of all zeros verifies against arbitrary content.
This was not hypothetical. eos_pkg.c:94 ships
static const uint8_t eos_pkg_public_key[32] = {0};
and the all-zero encoding is one of the eight. Measured against the built
library, before this change:
all-zero key: 2 of 256 arbitrary signatures ACCEPTED
Package installation authenticated nothing. The SHA-256 check two lines
above is no help — it compares a hash of the payload against a field in the
same attacker-supplied file, which detects corruption, not origin. See #98.
The check multiplies the key by L and requires the identity, rather than
comparing against a table of the eight encodings. Both are correct; this one
cannot be wrong in a way that silently rejects valid keys because a constant
was transcribed incorrectly.
Two things this took to get right, both found by testing rather than
reasoning:
The subgroup test alone is insufficient. The identity has order 1, which
divides L, so [L]identity = identity and it passes. The first version of
this check still accepted an identity key. It is now rejected explicitly,
which is why the helper has two parts.
The regression test's first version passed against the unfixed code. The
all-zero key is order 4, so with S = 0 the equation reduces to [k](-A) and
whether that lands on R depends on k mod 4 — on the message. The forgery
succeeds for roughly one message in four, and "untrusted firmware" happened
to be one of the three that do not verify. It looked like a regression test
and was not one. It now sweeps messages verified to be accepted by the
unfixed implementation.
unfixed test_all_zero_key_is_rejected [FAIL] at line 112
fixed 5/5 tests passed
The suite covers all 64 combinations of the eight low-order encodings as key
and as R, plus RFC 8032 vector 1 — without which a check that rejected every
key would also pass.
ctest 28/29 -> 29/29 passed, 0 build errors
This does not make package signing work. A correct verifier with an all-zero
trust anchor now rejects everything instead of accepting everything, which is
the safer failure and still not a functioning signature check. #98 covers
supplying a real key, and whether eos_pkg should refuse to run at all while
its anchor is unset — the shape #84 uses for stub RSA/ECC.
Refs #98
Co-authored-by: srpatcha <srikanth@embeddedos.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oup (#99) ed25519_verify() decoded the public key and never checked which subgroup it was in. Ed25519 has eight low-order points, and for any of them every term of the verification equation collapses regardless of the message, so a signature of all zeros verifies against arbitrary content. This was not hypothetical. eos_pkg.c:94 ships static const uint8_t eos_pkg_public_key[32] = {0}; and the all-zero encoding is one of the eight. Measured against the built library, before this change: all-zero key: 2 of 256 arbitrary signatures ACCEPTED Package installation authenticated nothing. The SHA-256 check two lines above is no help — it compares a hash of the payload against a field in the same attacker-supplied file, which detects corruption, not origin. See #98. The check multiplies the key by L and requires the identity, rather than comparing against a table of the eight encodings. Both are correct; this one cannot be wrong in a way that silently rejects valid keys because a constant was transcribed incorrectly. Two things this took to get right, both found by testing rather than reasoning: The subgroup test alone is insufficient. The identity has order 1, which divides L, so [L]identity = identity and it passes. The first version of this check still accepted an identity key. It is now rejected explicitly, which is why the helper has two parts. The regression test's first version passed against the unfixed code. The all-zero key is order 4, so with S = 0 the equation reduces to [k](-A) and whether that lands on R depends on k mod 4 — on the message. The forgery succeeds for roughly one message in four, and "untrusted firmware" happened to be one of the three that do not verify. It looked like a regression test and was not one. It now sweeps messages verified to be accepted by the unfixed implementation. unfixed test_all_zero_key_is_rejected [FAIL] at line 112 fixed 5/5 tests passed The suite covers all 64 combinations of the eight low-order encodings as key and as R, plus RFC 8032 vector 1 — without which a check that rejected every key would also pass. ctest 28/29 -> 29/29 passed, 0 build errors This does not make package signing work. A correct verifier with an all-zero trust anchor now rejects everything instead of accepting everything, which is the safer failure and still not a functioning signature check. #98 covers supplying a real key, and whether eos_pkg should refuse to run at all while its anchor is unset — the shape #84 uses for stub RSA/ECC. Refs #98 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
embeddedos-org#92 stays open on this point: `required_status_checks` is null on master, so nothing builds the merge result before it becomes master. Four individually green PRs combined into code that had never been compiled in the form it landed in, and the gap that allowed it is still open. Marking a check required is a settings change, but it needs a name worth pointing at. `release` is skipped on every pull request, and a required check that is skipped never reports, so the pull request waits for a status that never arrives. Requiring the four real jobs individually works only until the next one is added and silently sits outside the requirement. `ci-gate` is one job, one stable name, that succeeds only if every other job that runs on a pull request succeeded. `if: always()` so a real failure shows as a red X rather than a pull request that hangs. Any non-success result fails it, `skipped` included -- a job that did not run did not verify anything, and treating that as a pass is the fail-open shape embeddedos-org#75, embeddedos-org#82 and embeddedos-org#84 removed elsewhere. tests/unit/test_ci_gate.py fails if a job is added without being wired into the gate. Verified by mutation: dropping a job from `needs`, removing `if: always()`, renaming the gate, and adding an unwired job are each caught. pyyaml added to both pytest installers, since the test parses the workflow and neither installer had it. Verified: 16 Python tests pass, ctest 34/34.
embeddedos-org#92 stays open on this point: `required_status_checks` is null on master, so nothing builds the merge result before it becomes master. Four individually green PRs combined into code that had never been compiled in the form it landed in, and the gap that allowed it is still open. Marking a check required is a settings change, but it needs a name worth pointing at. `release` is skipped on every pull request, and a required check that is skipped never reports, so the pull request waits for a status that never arrives. Requiring the four real jobs individually works only until the next one is added and silently sits outside the requirement. `ci-gate` is one job, one stable name, that succeeds only if every other job that runs on a pull request succeeded. `if: always()` so a real failure shows as a red X rather than a pull request that hangs. Any non-success result fails it, `skipped` included -- a job that did not run did not verify anything, and treating that as a pass is the fail-open shape embeddedos-org#75, embeddedos-org#82 and embeddedos-org#84 removed elsewhere. tests/unit/test_ci_gate.py fails if a job is added without being wired into the gate. Verified by mutation: dropping a job from `needs`, removing `if: always()`, renaming the gate, and adding an unwired job are each caught. pyyaml added to both pytest installers, since the test parses the workflow and neither installer had it. Verified: 16 Python tests pass, ctest 34/34.
* 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) * ci: add one job branch protection can require #92 stays open on this point: `required_status_checks` is null on master, so nothing builds the merge result before it becomes master. Four individually green PRs combined into code that had never been compiled in the form it landed in, and the gap that allowed it is still open. Marking a check required is a settings change, but it needs a name worth pointing at. `release` is skipped on every pull request, and a required check that is skipped never reports, so the pull request waits for a status that never arrives. Requiring the four real jobs individually works only until the next one is added and silently sits outside the requirement. `ci-gate` is one job, one stable name, that succeeds only if every other job that runs on a pull request succeeded. `if: always()` so a real failure shows as a red X rather than a pull request that hangs. Any non-success result fails it, `skipped` included -- a job that did not run did not verify anything, and treating that as a pass is the fail-open shape #75, #82 and #84 removed elsewhere. tests/unit/test_ci_gate.py fails if a job is added without being wired into the gate. Verified by mutation: dropping a job from `needs`, removing `if: always()`, renaming the gate, and adding an unwired job are each caught. pyyaml added to both pytest installers, since the test parses the workflow and neither installer had it. Verified: 16 Python tests pass, ctest 34/34. * ci: gate every workflow that reports on a pull request, not just ci.yml Addresses the review on #121. Finding 1 was right and it was the important one: ci.yml is one of nine workflows that trigger on pull_request, so requiring `CI Gate` and stopping there left CodeQL, the QEMU boot test, the ARM64 kernel cross-build, the Python suites and the SBOM validation outside the requirement -- while the test written to stop that rot only ever read ci.yml. The scan now covers .github/workflows/*.yml, and REQUIRED_CHECKS pins the name a maintainer must require for each. Adding a workflow, or a job to one, fails the suite until the registry is updated. Six workflows are now covered. Two needed a gate job that did not exist (build.yml) or did not do its job (eos-simulation.yml, below). Three define a single job whose own name is stable and requirable, so they need no gate, and a test keeps them single-job. Three gate nothing on purpose -- auto-assign, claude-code-review and book-build all skip on ordinary pull requests, and a required check that is skipped never reports -- each recorded with its reason. eos-simulation.yml's `integration-summary` already had the shape of a gate: the right needs, if: always(), and a printed PASS/FAIL per dependency. It only exited non-zero for simulate-qemu, so build-kernel, test-middleware and test-cad-pipeline could all fail while it reported success. Anyone requiring that check would have believed three things were gated that were not. Finding 2: _only_runs_on_tags() was a substring test for "refs/tags", which also matches the inverted condition -- a job running on every pull request would have been classified as tag-only and dropped from the requirement silently. Replaced with a pinned CANNOT_RUN_ON_PR map, plus an assertion that each excluded job really does carry the condition claimed for it. Finding 3: the rule is now .github/scripts/ci-gate-check.sh, executed by the tests against real inputs -- success, failure, skipped, cancelled, mixed, and an empty context -- instead of the workflow's run: text being grepped for `!= "success"`. A test that greps an implementation passes for those tokens in a comment and fails for a correct rewrite. Every gate calls the one script, and a test enforces that too. Finding 4: dropped `2>/dev/null || true` from the pip install in python-tests.yml. Verified by mutation, each caught: a new pull_request workflow with no gate; a job added to build.yml outside its gate; a gate losing if: always(); a required display name renamed; the shared rule weakened to ignore `skipped`; and integration-summary reverted to gating only simulate-qemu. 28 Python tests pass, ctest 34/34, all 19 workflow files parse. * ci: let the full-stack gate run at all, and test that a gate can `Full-stack integration summary` invoked .github/scripts/ci-gate-check.sh with no `actions/checkout` step, so on this head the step exited 127 on a missing file. The gate's rule never ran: it was red because the script was not in the workspace, not because a dependency failed, and it could never be green -- so a maintainer following this PR's own instruction to require that name would have made master permanently unmergeable. That is the mirror image of the fail-open shape this PR exists to remove, and worse in practice: fail-open lets bad code through, this blocks everything. `test_every_gate_invokes_the_shared_script` passed on that commit, because it greps the job's `run:` text for the script name and a job that cannot execute the script still names it. That is the one remaining grep-the-YAML test in a file whose own docstring says the rule should be executed rather than pattern-matched, and it is exactly the test that should have caught this. So the fix and the test that would have caught it land together: - eos-simulation.yml: `actions/checkout@v4` as the first step of integration-summary, as ci-gate and build-gate already have. - test_every_gate_checks_out_the_repository_before_running_the_script: any gate whose `run:` names a repo-relative script must also have a checkout step. Fails without the fix. Three further hardenings to the same file: - The NOT_REQUIRED reasons were unverified free text and one had already drifted: book-build.yml was recorded as skipping because build-pdf is conditional, but its `validate` job carries no `if:` at all and the real reason it cannot be required is the `paths: ['docs/book/**']` filter on its pull_request trigger -- the never-arrives failure mode this PR is careful about everywhere else. The reason is corrected, and a test now asserts each NOT_REQUIRED workflow either carries paths/paths-ignore or gates every job behind an `if:`. auto-assign.yml and claude-code-review.yml pass it as written. - test_gate_script_exists_and_is_executable checked only `is_file()`. The gates pipe into the path itself, which needs mode +x; it is committed 100755, but `git update-index --chmod=-x` or a core.fileMode=false checkout would break all three gates with the suite green. It now asserts os.access(X_OK), and one case invokes the script directly rather than via `bash`, so the invocation path the workflows use is covered. - `_triggers_on_pull_request` handled only the mapping form of `on:`. `on: pull_request` and `on: [push, pull_request]` are both valid and both made a workflow invisible to the scan -- silently unclassified, which is the rot this file exists to catch. Latent, not live: all 19 current workflow files use the mapping form. Also: the comment at build.yml:174 pointed at `.github/STANDARDS.md`, which does not exist in this repository. STANDARDS.md is the org-wide file at embeddedos-org/.github; the comment now links it. Each of the four new assertions was mutation-checked: removing the checkout, chmod -x on the script, dropping book-build's paths filter, and rewriting a workflow to the list form of `on:` each fail the suite and each is caught by the intended test. pytest tests/ -q: 31 passed (18 -> 21 in test_ci_gate.py). ctest Release: 34/34. * ci: make each gate report under its own name The run that proved the checkout fix also showed the shared script naming the wrong check. `Full-stack integration summary` failed and logged: ::error::CI Gate failed. These jobs did not succeed: build-kernel: failure simulate-qemu: skipped The diagnosis is right and the check name is not: "CI Gate" was hardcoded in ci-gate-check.sh, so two of the three gates report a failure under a check name that is not theirs. In a pull request whose subject is checks that report something other than what they measured, that is not a cosmetic detail -- a maintainer reading the log of a red merge is told to go look at a check that is green. The script now takes `GATE_NAME` (defaulting to "Gate"), each gate step sets it to its own display name, and two tests hold it in place: one runs the script with GATE_NAME set and asserts the message uses it, one asserts each gate declares the display name that REQUIRED_CHECKS says it reports under. Both mutation-checked -- hardcoding the name in the script fails the first, giving build-gate the wrong name fails the second. pytest tests/ -q: 33 passed. --------- Co-authored-by: Srikanth Patchava <srikanth.patchava@outlook.com>
Contribution after reviewing the project: RSA/ECC verify already fail-closed unless
EOS_ALLOW_STUB_CRYPTOis defined, but sign still minted fake signatures in production builds.Issue
services/crypto/src/rsa_ecc_sha512.cdocuments that the RSA/ECC routines are stubs, not PKCS#1/ECDSA. After the earlier verify-side fix,eos_rsa_verify_sha256/eos_ecc_verifyrefuse to run without the opt-in.eos_rsa_sign_sha256still wrote a PKCS#1-looking blob ending in the hash and returned 0;eos_ecc_signstill wrote a fake 64-byte r||s and returned 0. A signing pipeline could look like it worked while producing forgeries.tests/test_crypto_failclosed.calready asserts that signing must refuse, and is listed among unregistered tests. It was not intests/CMakeLists.txt.Approach
Call
EOS_STUB_CRYPTO_REFUSEfrom both sign functions after the existing NULL checks, matching verify. Registertest_crypto_failclosedagainsteos_cryptowithoutEOS_ALLOW_STUB_CRYPTOso CI covers the production path. Existingtest_crypto_rsa/test_crypto_ecconly(void)the sign/verify status, so they still pass.Testing
The fail-closed suite checks:
I did not run CTest here (no local checkout).
test_crypto_rsa.c/test_crypto_ecc.cwere read to confirm they do not assert sign success.Limitations
Does not implement real RSA/ECDSA. Tests that want the old stub behavior must define
EOS_ALLOW_STUB_CRYPTO(none of the currently registered tests do). Master currently has duplicatetest_crypto_aes/test_crypto_sha512CMake targets, so a full test configure still fails until that is fixed independently; this PR only addstest_crypto_failclosed.