Repository navigation
Description
Symptom
A server (or MITM) that sends a low-order X25519 key_share forces tls_shared_secret to all zeros. Every handshake and application key then becomes a deterministic function of the plaintext ClientHello and ServerHello — so any passive observer who recorded the traffic can decrypt the session. The attacker who injected the bad key_share need not stay on the path.
Cause
RFC 8446 §7.4.2 and RFC 7748 §6.1 both require the check:
the recipient MUST check whether the computed shared secret is the all-zero value and abort if so
There is no such check. src/tls_ecdh.s:110-117:
; Copy x25_result -> tls_shared_secret
ldx #31
@copy_ss:
lda x25_result,x
sta tls_shared_secret,x
dex
bpl @copy_ss
rts
None in x25519_scalarmult (src/crypto/x25519.s:97), and none in tls_derive_handshake_keys before it hands tls_shared_secret to hkdf_extract as IKM (src/tls_keyschedule.s:166-174). grep -rn 'all-zero\|small.order\|low.order' src/ returns nothing crypto-related.
The attack: put 32 zero bytes in the ServerHello key_share. It passes every check the parser makes — src/tls_handshake.s:495-510 validates only that the group is 0x001D and the key length is 0x0020, then copies verbatim. x25519_scalarmult masks the high bit per RFC 7748 decodeUCoordinate and computes; x25519(k, 0) = 0 for every clamped scalar k. The other low-order points work the same way.
Consequence, worked through the key schedule
Following src/tls_keyschedule.s:90-182:
early_secret = HKDF-Extract(0^32, 0^32)— constant by constructionderived = Derive-Secret(early_secret, "derived", empty_hash)— constanthandshake_secret = HKDF-Extract(derived, tls_shared_secret)— now also constant, because the IKM is a constant
Everything below is Derive-Secret(constant, label, Transcript-Hash(CH||SH)), and CH||SH are plaintext on the wire. Both handshake traffic secrets, and via tls_derive_traffic_keys both application traffic secrets, are computable by anyone who saw the two hello messages.
This is qualitatively worse than the documented "an active attacker can impersonate any server" caveat, because it is a passive break. It defeats README.md:166-168 — "Treat the transport as confidential against passive observers" — which is the property the project explicitly retains after conceding authentication.
It also renders the server Finished check vacuous in the same breath, since the verifier's key is now public.
Fix
After jsr tls_ecdh_compute_shared (src/tls13.s:512), OR the 32 bytes of tls_shared_secret together and branch to @sh_error if the result is zero. ~12 bytes. Cheapest high-value fix in the set.
Belt-and-braces alternative: reject the all-zero key_share at parse time in src/tls_handshake.s:495-510 as well. The output check is the one the RFC mandates and catches every low-order point, not just the zero one, so it is the required half.
Test
A VICE-level test can drive tls_ecdh_compute_shared with a zero tls_server_pubkey and assert the carry, with no listener and no hardware. A wire-level negative case belongs alongside the CERT_MODE=omit flight in tools/https_e2e/evil_listener.py.
Found by adversarial review, 2026-08-30. Verified independently at source before filing. Should be fixed in the same PR as #152 — together they are "what does this client actually guarantee", and shipping one without the other leaves a misleading story in the README.
Activity
JC-000 commentedon Aug 30, 2026
Companion: #152 (handshake sequence unenforced). Both were found in the same adversarial review and should be fixed in one PR — together they define what the client actually guarantees, and the README rewrite depends on both.
Merge pull request #162 from JC-000/fix/153-x25519-all-zero-shared-se…
docs(readme): point at v0.4.3, the security release
docs(readme): point at v0.4.3, the security release
docs(readme): point at v0.4.3, the security release