Files
ESP32_Serial_Swiss_Army_Knife/docs/ssh_key_validation_review.md
T
Commander1024 4d3bb490c9 Harden wolfSSL and wolfSSH validation
Enable validated ECC imports and X25519 all-zero rejection through
PUBLIC build policy. Tighten wolfSSH parser bounds, overflow handling,
and signature framing with guard-page and crypto vector contracts.
2026-09-15 23:54:39 +02:00

16 KiB
Raw Blame History

SSH key-validation review — 2026-09-15

Decision and scope

The baseline P-256 ECDH and X25519 validation gaps are mitigated by project-owned compile policy. Parent firmware build and local strict crypto suite PASS; the broader SSH review and hardware gates remain open. This supplements, rather than silently rewrites, the historical wolf review.

  • Enable upstream WOLFSSL_VALIDATE_ECC_IMPORT for wolfSSL 5.8.2. The SSH server imports an unauthenticated P-256 peer point and otherwise reaches scalar multiplication without an on-curve check.
  • Enable existing upstream WOLFSSL_ECDHX_SHARED_NOT_ZERO. The SSH X25519 input precheck does not reject every low-order input; the result check was disabled.
  • These are PUBLIC definitions in cmake/wolf_crypto_policy.cmake, with resolved-settings checks in cmake/wolf_crypto_policy.h. No root CMakeLists.txt edit is necessary: it already includes this module after project(). The existing small X25519/Ed25519 policy and RNG/acceleration controls are preserved.
  • No dependency version, installed vendor source, generated override, application source, or device change was made by this task. Edits are restricted to the assigned policy files, tests/wolf_crypto_policy/, and this report.
  • This is not a demonstrated long-term-key recovery, authentication bypass, remotely measured exploit, full upstream backport, or release approval. ECDH uses a freshly generated ephemeral key, separate from the long-term signing identity.

Exact local evidence

Inspected installed wolfSSH 1.4.20 / wolfSSL 5.8.2~1 and the generated wolfSSH input located through .pio/build/esp32-s3-devkitc-1-n16r8/compile_commands.json. SHA-256 snapshot:

Input SHA-256
managed_components/wolfssl__wolfssh/src/internal.c 81ff1f9166708abd5c2911e9fe57c0aee01c88b5d3f68c909ee8a856d37f36a9
.pio/build/esp32-s3-devkitc-1-n16r8/security_overrides/wolfssh_internal/internal.c 9e5923d536cee049409a7df471d155a6dd2e804d55a1528f5c4abc8e34807dea
managed_components/wolfssl__wolfssl/wolfcrypt/src/ecc.c 909c57e2756a8002df9f1d214483c659cb20db4a5f51047eda62d64ac458db06
managed_components/wolfssl__wolfssl/wolfcrypt/src/curve25519.c 9a0f6f0205245a8d19500a936d9b02bb71c8656713648408d1cb408362694b76
managed_components/wolfssl__wolfssl/wolfcrypt/src/signature.c 62ab3db3dfd251b2a2c73b69ef05aab6085d2e0d673fd9159514b3ee261cea4f

The original and generated bodies of HashForId, KeyAgreeEcdh_server, KeyAgreeCurve25519_server, SignHEcdsa, and DoUserAuthRequestPublicKey remain byte-identical. DoUserAuthRequestEcc and DoUserAuthRequestEd25519 now contain the reviewed label/framing corrections from tools/security_overrides.py. The crypto suite independently reconstructs only those exact deltas from the hash-pinned original, requires exact anchor counts, and compares all seven complete functions with the actual generated compilation input. It does not derive its expected bodies from the generator being checked, skip changed functions, normalize away changes or relax the original hashes. Any additional change requires re-review. This is not a whole-generator equivalence test.

Generated-source line references below retain the initial pre-parser-correction snapshot; use the named functions in the current generated source, whose hash is recorded above.

Before changing policy, actual Xtensa preprocessing of ecc.c confirmed USE_FAST_MATH, ECC_TIMING_RESISTANT, WOLFSSL_SMALL_STACK, HAVE_ECC_CHECK_KEY, and internal HAVE_ECC_CHECK_PUBKEY_ORDER; neither validation definition was present. USE_ECC_B_PARAM and SP math were absent. Initial candidate replay checked the proposed definitions. Following the parent build, strict mode now passes without injecting policy flags, checking the saved production compiler/includes/definitions and syntax for twelve library/SSH/application translation units, including ecc.c and signature.c. Parent-reported pio run PASS: 94,340 B linked RAM / 1,768,949 B flash. This agent did not rerun PlatformIO or inspect a flashed image; compile-profile checks and the supplied link/build result are distinct evidence.

P-256 import and ECDH

Generated internal.c:1125111317, KeyAgreeEcdh_server:

  1. Derive primeId from negotiated KEX; project policy allows ecdh-sha2-nistp256 and X25519.
  2. Initialize public/private keys; attach the session RNG to the ephemeral private key.
  3. wc_ecc_import_x963_ex(handshake->e, eSz, pubKey, primeId) imports the peer point.
  4. Only after successful import, generate a fresh private key, export its public point, and call wc_ecc_shared_secret.
  5. Errors propagate; both key objects are freed. The policy adds no task, queue, retry loop, protocol control sequence, or new entropy source.

Installed ecc.c:1070911020 parses X9.63 coordinates and selects the curve. At 1099310996, wc_ecc_check_key is called only with WOLFSSL_VALIDATE_ECC_IMPORT. Merely compiling HAVE_ECC_CHECK_KEY is not equivalent. The inferred-curve wc_ecc_import_x963 wrapper uses the same implementation.

Without this flag, wc_ecc_shared_secret (46694752) checks pointers, private-key type, domain metadata and matching curve IDs, not whether the peer coordinates satisfy the curve equation. Its software path reaches wc_ecc_shared_secret_ex (5102), wc_ecc_shared_secret_gen_sync (4759), and wc_ecc_mulmod_ex2 (4942 vicinity), using curve A, prime and order. No equivalent point-validation call precedes the multiplication. The absent import validation is applicable, not just an unproven macro concern.

With the flag, _ecc_validate_public_key (1049510703) checks infinity, coordinate ranges, the curve equation and public-point order; private imports additionally check the private range and private/public consistency where applicable. It loads B from key->dp->Bf even when USE_ECC_B_PARAM is absent. There is no need to force B storage or reproduce the larger upstream source refactor. The guard rejects configurations that disable the software validator or route it to the known successful hardware stubs; it does not claim arbitrary future backends are validated.

The work is bounded by the selected curve and existing key/input limits, but not free: valid imports incur public validation, including order checking, and private/public consistency checks can add multiplication and allocations. Target latency, memory peaks, stack margins, repeated-handshake/rekey load and the existing deadlines must be measured. Existing admission limiting is not evidence that this cost is harmless.

X25519 all-zero result

Generated internal.c:1133511394, KeyAgreeCurve25519_server, explicitly runs wc_curve25519_check_public before import, key generation and shared-secret calculation. Installed curve25519.c:645710 rejects wrong lengths, zero/one, the high bit, and the upper-end range in the little-endian path. This is not a complete low-order rejection rule.

The real vendor small-math tests exercise two nontrivial low-order u-coordinates that pass that precheck:

  • e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800
  • 5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157

wc_curve25519_shared_secret_ex (452536) already contains a 32-byte OR reduction under WOLFSSL_ECDHX_SHARED_NOT_ZERO. It returns ECC_OUT_OF_RANGE_E for an all-zero result before copying it to the caller, then wipes its temporary. Neither the current SSH server helper nor subsequent successful-KEX processing adds an equivalent result test. Enabling this existing check is sufficient for the inspected software path. Tests verify rejection of both inputs, unchanged caller output on rejection, ordinary zero/one precheck rejection, and the valid RFC7748 shared secret. No custom blacklist or small-math/blinding combination is introduced.

Raw signatures and CVE-2026-5194 applicability

No attacker-selected short digest/OID-confusion trigger was found in the reviewed current raw SSH authentication/signing paths. The separate ECC/Ed25519 label and signature-framing defects have now been corrected by the parser owner, as reviewed below; that does not backport generic crypto API hardening.

  • src/ssh_transport.c:489 calls user_database_authorize_ssh_public_key before wolfSSH signature verification. src/user_database.c:185 vicinity checks exact embedded type, exact nistp256, 65-byte uncompressed point and end-of-blob; mbedTLS parses and checks the point. user_database_authorize_ssh_public_key:810 repeats validation and requires exact stored key-type/blob matching for the named account. Thus the server user-key path already has an independent P-256 point-validation boundary, unlike unauthenticated KEX. The new wolfSSL import check is defense in depth here.
  • Generated DoUserAuthRequestPublicKey:7324 vicinity derives hashId = HashForId(pkTypeId), obtains digestSz from wc_HashGetDigestSize, and hashes the session ID and authentication message locally. HashForId maps P256 to SHA256: the digest is 32 bytes, not a peer-supplied digest length. The untrusted signature type does not choose an alternate prehash independently of that authorized key type.
  • Generated DoUserAuthRequestEcc:6851 vicinity imports Q, converts raw r/s through wc_ecc_rs_raw_to_sig, and calls wc_SignatureVerifyHash with that locally computed digest and length. Installed signature.c:131 only checks that the hash type exists, not equality of hash_len with its size; ecc.c:9204 checks r/s ranges but predates the new minimum-digest check. These upstream API weaknesses remain in the dependency, but the reviewed caller supplies the correct size.
  • SignHEcdsa:11676 hashes exchange hash H using HashForId(handshake->pubKeyId) and signs the resulting full SHA256 digest for the allowed P256 host key. This is distinct from ECDH.
  • Client host verification (DoKexDhReply, generated calls near 5651/5685) uses wc_SignatureVerify, which computes the full digest before verification. Client BuildUserAuthRequestEcc derives its digest size from HashForId(keySigId). These are not the intended application's server authentication role. Existing message-order concerns mean role alone must not substitute for validating dispatch reachability.
  • Certificate variants (DoUserAuthRequestEccCert, BuildUserAuthRequestEccCert) are under disabled WOLFSSH_CERTS. Current Ed25519 auth takes its separate message/streaming-verification path, not an attacker-sized Ed25519ph digest. Ed448 and ML-DSA are not current SSH algorithms.
  • Host private DER decoding goes through wc_EccPrivateKeyDecode in installed asn.c:35833; template parsing calls wc_ecc_import_private_key_ex near 36033. Real host tests cover valid SEC1 P256 private/public import without a pre-attached RNG, plus rejection of a corrupted embedded public point. This is not an NVS lifecycle or identity-rotation test.

The compile-policy mitigation does not backport PR10131's global digest-length/OID enforcement. Reassess if new raw APIs, certificates, key types, callbacks or client roles are enabled.

Parser-owner corrections reviewed and remaining work

The following formerly pending gaps are fixed in the current generated input, not by the crypto compile flags:

  1. Both key/signature label checks in DoUserAuthRequestEcc and DoUserAuthRequestEd25519 now use OR. Unequal lengths reject before memcmp; equal lengths compare the bounded expected span. This rejects equal-length wrong labels and avoids comparing an oversized label against the shorter expected label. Existing error normalization remains unchanged.
  2. ECC GetSize first proves sz <= signatureSz - i; converting sz to the absolute end with sz += i therefore cannot wrap. Both GetStringRef calls use that end, not the outer field size. The subsequent i != sz || sz != pk->signatureSz rejection requires exact inner and outer consumption before conversion or verification.
  3. Ed25519 requires sz == pk->signatureSz - i before starting streaming signature verification. Trailing bytes outside the declared signature string now reject.

Reviewed tools/security_overrides.py and tests/wolfssh_parser_contract/{run.py,README.md,auth_framing.c} against exact original/generated function diffs. These deltas leave ECC digest creation, raw-to-DER conversion and crypto calls, and Ed25519 streamed-message construction unchanged. Valid framing is retained; previously tolerated malformed labels/trailing bytes reject. No exploit or authentication-bypass demonstration is claimed.

The parser suite uses extracted generated functions, guard pages, instrumented nested reads, UBSan trap mode and crypto doubles. Its documented 3,124 cases per stack mode and six guard-removal mutations concern parser gating, not actual signature arithmetic. In contrast, this crypto suite executes real installed vendor arithmetic/ASN/wrappers and separately checks exact generated parser deltas and production compilation settings. Neither suite is an end-to-end SSH handshake test.

Parent build plus local strict crypto validation resolve the earlier build/profile handoff; no root edit is requested. Remaining work: broader ordering/state-machine and deferred parser/advisory review, standalone ECC curve-name/key-blob semantic validation if that dependency path is used without the application's existing checks, generic PR10131 API hardening as applicability requires, and whole-phase hardware/resource/latency tests. Later changes to audited functions still require explicit delta review, not silent repinning.

Tests and limitations

Commands run for this task:

python3 tests/wolf_crypto_policy/run.py --host-only
python3 tests/wolf_crypto_policy/run.py --candidate
# Follow-up after parent firmware build:
python3 tests/wolf_crypto_policy/run.py

Follow-up strict suite PASS, including all host vectors and private ASN-decode cases, seven exact source-body checks with independently specified parser deltas, and production flags without candidate injection. Earlier host-only/candidate runs also passed. Parent build evidence is supplied, not rerun here; no device validation was performed. Details are in the companion test README. Coverage includes 20 guard cases; a host CMake fixture executing the real module over a library → SSH → app graph; real installed small-X25519/Ed25519 and TFM P256 crypto; explicit/inferred import rejection for off-curve, infinity, out-of-range, truncated and wrong-tag points; valid ECDH; raw/DER valid and invalid ECDSA verification; private-key ASN import; exact source checks; twelve strict production target macro/syntax checks; four real-settings missing-policy rejection cases.

Host settings use software TFM, ECC timing resistance and small-stack allocation, but host word size, allocator, OS entropy, compiler and absent ESP acceleration differ from firmware. The CMake fixture is not the full ESP-IDF graph. No exhaustive Wycheproof/fuzz campaign, allocator-failure injection, crypto-suite sanitizer execution, network handshake, real rekey, timing/side-channel measurement, stack/heap reserve measurement, agent-performed firmware link, flashing or hardware validation is claimed. The parent-reported firmware build/link and size figures above do not establish runtime reserves. Test development exposed host fixture omissions (POSIX declarations, wolfmath linkage, filesystem RNG and ASN settings); those were corrected without modifying vendor sources.

External sources rechecked

Read-only retrieval on 2026-09-15:

  • wolfSSL PR10133 diff: removes conditional B/on-curve gating and treats wc_ecc_import_x963_ex input as untrusted by default in the later tree. This is not a directly applied patch to 5.8.2.
  • 5.9.1 tagged ChangeLog, Bug Fixes: explicitly recommends WOLFSSL_VALIDATE_ECC_IMPORT for users of older versions. This is the basis for the bounded policy choice.
  • wolfSSL PR10374 diff: makes X25519/X448 all-zero checking opt-out. The existing 5.8.2 opt-in macro enables the inspected equivalent synchronous result check; later nonblocking/TLS changes are not imported.
  • wolfSSL PR10131 diff: certificate signature-OID/key-type consistency plus raw digest-size hardening; used to distinguish the current SSH caller contract from unpatched generic API behavior.

PR URLs are mutable and are not an archived commit-pinned upstream evidence bundle. Local original source hashes above and the source-contract tests bound the implementation inspected here.