Skip to content

PQ P3: sectioned ClientHello (16-bit lengths) + HelloRetryRequest refusal - #312

Merged
JC-000 merged 3 commits into
masterfrom
feat/pqc-p3-clienthello
Oct 17, 2026
Merged

JC-000 merged 3 commits into
masterfrom
feat/pqc-p3-clienthello

Conversation

@JC-000

@JC-000 JC-000 commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

PQ hybrid, phase P3: sectioned ClientHello + HelloRetryRequest refusal

No ML-KEM dependency. Default (X25519-only) ClientHello bytes are unchanged.

What changed

  • tls_build_client_hello is table-driven (length-prefixed templates in ch_tmpl, CODE) and stages head+tail in tls_rec_buf. Real 16-bit extensions/handshake/record lengths.
  • Optional bulk key-share section: tls_ch_ks_ptr/len (16-bit; BSS zeroed at boot; cleared by every send, success or failure) + tls_ch_split. tls_ch_send_sections sends record header, head, bulk (e.g. the 1,184 B ek from cert_buf), tail, folding each into the transcript in wire order. With a bulk section the hello offers groups 11EC,001D and key_share 11EC(bulk||x25519) + 001D(x25519). Length 0 (every caller today) = classic X25519-only hello.
  • Build switch CH_SECTIONED (src/constants.inc, follows TLS_STREAM_DEFRAME, i.e. UCI). Not a Makefile knob. ip65 keeps the table-driven X25519-only builder (no sectioned sender).
  • HRR: tls_sh_chk_hrr (called from tls_parse_server_hello, both backends) refuses a ServerHello whose random is SHA-256("HelloRetryRequest"): C=1, tls_sh_err = $01. tls_sh_err is cleared at parse entry; it is diagnostics-only and can go stale across a connection that fails before SH parse. On ip65 the check lives in CODE (LOADER), on UCI in TLS_CODE.
  • Note: a ~1.2 KB hybrid ServerHello is not deframed today (tls_recv_server_hello is single-record; servers fragment it under MFL=512). That is the next phase.

Record framing decision

One TLS record carries the whole (up to ~1.4 KB) ClientHello. RFC 8446 5.1 permits fragmenting handshake messages, but the CH precedes max_fragment_length negotiation (MFL is ext 0x0001, RFC 6066 4; it binds records only after the server echoes it), so the 2^14 default applies and a single record is legal; it also avoids needing reassembly in the peer.

Arming contract (S3)

Caller sets tls_ch_ks_ptr/len immediately before jsr tls_connect, after DNS/TCP connect succeed. tls_connect reaches the sender with no branch in between and the sectioned sender clears the length on every exit, so nothing stays armed past one ClientHello. I did not clear at tls_connect entry: the caller arms before the call, so an entry clear would discard a fresh arm; and a failure before tls_connect is never reached cannot be fixed from inside it. Test [2k] fails each of the four sends in turn (k=1..4) and asserts C=1 and tls_ch_ks_len=0.

P4 will move ML-KEM KeyGen + arming inside tls_connect (between tls_ecdh_generate_keypair and tls_send_client_hello), retiring the caller contract and using the per-connection DRBG reseed.

Byte identity (default builds)

tools/test_tls_handshake.py [2d-2f] capture the wire via a recording net_tcp_send stub (C64_CH_DUMP=<dir> writes it). sha256 identical master vs branch on both backends:

  • 2d (example.com) 5246919c788197b853466ca94ef055a5814cd2afae491010f690fe28c08a1218
  • 2e (no SNI) 71b0ab6eccec788d9c60bcc31a6780b271ec365d3d4ce5d71783127c48d4475e
  • 2f (63-char SNI) ec0434a260f9b96bc9fc62a056ff80b07f7b2eaa6807d5800252d634f881e778
    The adversarial review's 64-host-length sweep (host lengths 0..63 -> 64 cases) was byte-identical to master on uci, ip65 and uci-onchip-store: 64/64 each.

PRG hashes (default make, from measure_margins.py --build, head of this PR)

profile master branch
ip65 6e20ce56... 3fccbeb1...
ip65-onchip 1c377d34... e69bfce7...
uci 97072869... fe3b6c20...
uci-onchip 5e709d88... 9e11f570...
uci-comb c13b3e99... ad85797b...
uci-onchip-store 98a3d882... 1e824348...

Margins (tail free, B; master -> branch)

region ip65 ip65-onchip ip65-wiki ip65-onchip-wiki uci uci-onchip uci-onchip-store (shipped: ONCHIP TRUST_STORE=1 TRUST_RELEASE=1) uci-comb uci-comb-store uci-comb-demo-store
LOADER 397 -> 523 397 -> 523 397 -> 523 397 -> 523 614 -> 616 614 -> 616 592 -> 594 197 -> 199 82 -> 84 25 -> 27
NET_CODE 236 -> 236 236 -> 236 194 -> 194 194 -> 194 727 -> 530 735 -> 538 735 -> 538 727 -> 530 589 -> 392 589 -> 392
CRYPTO_OVERLAY 411 -> 411 377 -> 377 411 -> 411 377 -> 377 2636 -> 2636 2578 -> 2578 372 -> 372 317 -> 325 47 -> 55 29 -> 37
CRYPTO_RESIDENT / HOT 619 -> 627 621 -> 629 619 -> 627 621 -> 629 457 -> 465 501 -> 509 188 -> 196 1387 -> 1387 1259 -> 1259 70 -> 70
Full before/after tables from --build: scratchpad margins_before.txt / margins_after2.txt. The only region that got worse is UCI NET_CODE (-197: sectioned sender + HRR check); ip65 CRYPTO_OVERLAY is unchanged (HRR check moved to LOADER on ip65).

Tests (red/green)

New in test_tls_handshake.py: [2d-2k] ClientHello wire vs an independent Python reference through a recording net_tcp_send (classic x3; sectioned bulk=1184/448/182/40, no-SNI, 63-char SNI; bulk at the non-page-aligned $C433, not cert_buf; each of the 4 sends failing in turn clears tls_ch_ks_len; checks 16-bit lengths, 4 sends, bulk sent from its own address, transcript == sha256 of the message); [3d] HRR magic -> C=1/err=1, byte-31 near-miss, byte-0 near-miss and a normal random -> C=0/err=0.

  • RED on master (uci, --seed 7): 24/26, FAIL label 'tls_ch_ks_ptr' missing and FAIL label tls_sh_err missing; ip65 master 24/25 (HRR). The red is missing labels, not wrong bytes.
  • GREEN on branch (head of this PR): uci 36/36, ip65 25/25.
  • Mutants (review round), both previously survived, now killed:
    • bpl :- -> bne :- in tls_sh_chk_hrr (skips byte 0): 3d FAIL: (C,err) near-miss0=(1, 1), suite 32/33.
    • hardcoded lda #$00 / ldx #$42 for the bulk pointer in tls_ch_send_sections: 2j FAIL: bulk not sent from its own buffer, suite 32/33.
    • round 2, previously surviving, now killed (all tls_handshake 35/36):
    • earlier: zeroing the extensions_length high byte -> 5 sectioned cases fail.
  • BACKEND=uci python3 tools/run_all_tests.py: TOTAL: 455/455 passed, 0 failed -- 2 suite(s) SKIPPED: net, ip65_exports.
  • ip65 python3 tools/run_all_tests.py (first round, before this fixup): TOTAL: 443/443 passed, 0 failed -- 1 suite(s) SKIPPED: hs_sequence; re-run this round: test_tls_handshake.py ip65 25/25 only.

OpenSSL interop (adversarial review, OpenSSL 3.6.5)

The hybrid CH (1364 B record, 11EC entry 1216 B) drew: an 11ec ServerHello (key_exchange 1120 = ct 1088 + 32) for groups X25519MLKEM768 (and both orderings with X25519); a 001d ServerHello under X25519 only; a handshake_failure alert under P-256 only.

Hardware (U64E 10.43.23.81, 48 MHz, via DeviceLock)

  • rig_https_local.py, uci-onchip + HTTPS_SNI=www.foo.invalid: PASS on this head, PRG sha256 bb323255875629e0b587b0f7ef042e856348806ce11e908b554f28135983341c (round 1, previous head: 3a519db6... PASS).
  • rig_https_live.py against github.com, ALL runs: (1) round 1 PR build with the HTTPS_SNI=www.foo.invalid PRG by my mistake (rig_live.txt 20:45): FAILED in Certificate, tls_state=$FF, tls_last_state=$04, http_status=0 (name mismatch from the override, not the code); (2) PR build 24477609 without override: HTTP 200 + carry=1; (3, 4) the adversary re-ran 24477609 twice: HTTP 200 + carry=1 each. Tally: 3 x HTTP200/carry=1, 1 x CERT fail. Unmodified master (5e709d88) also gives HTTP200/carry=1 on github.com (body truncation, rig_https_live against github.com fails ~1 run in 3 on the U64E, on master as well as on branches #147/http_get reports success (carry=0, HTTP 200) on a truncated body — silent data loss #211). The live rig prints no PRG hash (it links via label addresses), so those hashes are from my build step, not rig output.
  • browserleaks.com: fails identically on master and branch (tls_last_state=$06, net_last_error=$87).
  • Round 2 changed only tests, comments and CLAUDE.md in this head; the PRG bytes are unchanged from the round-1-fixup head the local rig ran on (bb323255...), so no rig was re-run.

Not in this PR

Only X25519 is offered by any shipped code path; nothing sets tls_ch_ks_len. The sectioned path is exercised only by tests. ServerHello with 11EC and the key schedule are separate phases.

🤖 Generated with Claude Code

JC-000 and others added 3 commits October 16, 2026 20:42
…Request (PQ P3)

Table-driven ClientHello builder, optional bulk key-share section streamed
as one record (UCI only, CH_SECTIONED), per-section transcript folds, and
tls_sh_err=1 for an HRR-magic ServerHello random. X25519-only wire bytes
are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…heck to LOADER, arming contract, comment/CLAUDE.md fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…AUDE.md fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant