nx_tls13_client_session_recv_sh.nx source
↩ module page · 270 lines · 12786 B
1// nx_tls13_client_session_recv_sh.nx -- step 3c.2 of the
2// nx_https_client wiring arc.
3//
4// Consumes the bytes of a TLS 1.3 ServerHello handshake message
5// (provided by the caller -- this step doesn't do fd IO; that's
6// the orchestrator wrapper 3c.6 in a later commit) and walks the
7// full handshake-key-derivation cascade:
8//
9// 1. Parse the ServerHello message (via shipped tls13_server_hello_parse)
10// 2. Find the key_share extension + extract server's X25519 pubkey
11// 3. Compute shared_secret = x25519(my_priv, server_pub)
12// 4. Run TLS 1.3 key schedule cascade (RFC 8446 §7.1):
13// early_secret = HKDF-Extract(0, 0)
14// derived_1 = Derive-Secret(early, "derived", "")
15// handshake_secret = HKDF-Extract(derived_1, shared_secret)
16// 5. Feed ServerHello bytes into transcript (so H1 = hash(CH || SH))
17// 6. Snapshot transcript -> H1
18// 7. Derive c_hs_traffic_secret + s_hs_traffic_secret using H1
19// 8. Expand each into AEAD key (32B) + IV (12B for ChaCha20-Poly1305
20// or AES-GCM)
21// 9. Reset per-direction sequence numbers to 0
22// 10. Advance state CH_SENT -> WAIT_EE
23//
24// After this primitive returns OK, the session is ready for the
25// 3c.3 step (recv encrypted handshake records, decrypt with
26// server_hs_traffic_key, dispatch via tls13_client_dispatch_with_validation).
27//
28// Public API:
29// nx_tls13_client_session_recv_sh(session, sh_bytes, sh_len) -> verdict
30// nx_tls13_recv_sh_verdict_is_valid(v) -> 0|1
31//
32// Sealed verdict enum:
33// NX_TLS13_RECV_SH_OK keys derived, state advanced
34// NX_TLS13_RECV_SH_BAD_STATE session not in CH_SENT
35// NX_TLS13_RECV_SH_BAD_FORMAT SH parse failed
36// NX_TLS13_RECV_SH_NO_KEYSHARE SH extensions missing key_share
37// NX_TLS13_RECV_SH_BAD_GROUP key_share group != X25519 (29)
38// NX_TLS13_RECV_SH_BAD_KEYSHARE_LEN server keyshare != 32 bytes
39// NX_TLS13_RECV_SH_INTERNAL HKDF/SHA-256/x25519 failed
40//
41// Per Cardinals 9 (single-responsibility -- ONE handshake step),
42// 12 (defensive at boundaries -- state check + every parse-step
43// verdict mapped explicitly), 19 (API contract stability --
44// composes shipped primitives without modifying them), 22
45// (composition -- 7 shipped primitives compose into one step), 23
46// (preamble names the queued next step).
47//
48// license_tier: INDEPENDENT_REDERIVE
49// genealogy_id: international-research-sources/ietf/rfc_8446
50// lineage_id: nishi_tls13_client_session_recv_sh_q10
51
52// nx_safety_envelope:
53// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
54// sil_target: SIL1
55// evidence: [bulk_applied_2026-05-19, tls13-client-recv-sh-step-3c2]
56// verdict: NOT_YET_EVALUATED
57
58import "nx_syscalls.nx"
59import "nx_sha256.nx"
60import "nx_x25519.nx"
61import "nx_tls13.nx"
62import "nx_tls13_hello.nx"
63import "nx_tls13_transcript.nx"
64import "nx_tls13_kdf.nx"
65import "nx_tls13_schedule.nx"
66import "nx_tls13_client_session.nx"
67import "nx_p256_ecdh.nx"
68
69const NX_TLS13_RECV_SH_OK: i64 = 1
70const NX_TLS13_RECV_SH_BAD_STATE: i64 = 2
71const NX_TLS13_RECV_SH_BAD_FORMAT: i64 = 3
72const NX_TLS13_RECV_SH_NO_KEYSHARE: i64 = 4
73const NX_TLS13_RECV_SH_BAD_GROUP: i64 = 5
74const NX_TLS13_RECV_SH_BAD_KEYSHARE_LEN: i64 = 6
75const NX_TLS13_RECV_SH_INTERNAL: i64 = 7
76const NX_TLS13_RECV_SH_BAD_CIPHER: i64 = 8 // server picked cipher we don't speak
77// A TLS-1.2 suite in a ServerHello is NOT a cipher gap -- it means the server DOES NOT SPEAK TLS 1.3 and
78// negotiated down. Folding that into BAD_CIPHER is what sent two sessions hunting a cipher implementation
79// for graphis.ne.jp (Apache 2.2.31 / OpenSSL 1.0.0-fips, which predates RFC 8446 by eight years and can
80// never do 1.3). The two conditions have OPPOSITE remedies -- BAD_CIPHER means BUILD the suite, NOT_TLS13
81// means STOP and use the 1.2 leg -- so a verdict that cannot tell them apart sends the reader the wrong way.
82// ★A DIAGNOSTIC THAT MERGES TWO CAUSES WITH OPPOSITE FIXES IS WORSE THAN NO DIAGNOSTIC.
83const NX_TLS13_RECV_SH_NOT_TLS13: i64 = 9 // server negotiated DOWN: chose a non-0x13xx suite
84const NX_TLS13_RECV_SH_VERDICT_N: i64 = 10
85
86const NX_TLS13_GROUP_X25519: i64 = 29
87const NX_TLS13_GROUP_SECP256R1: i64 = 23
88
89func nx_tls13_recv_sh_verdict_is_valid(v: i64) -> i64 {
90 if v < NX_TLS13_RECV_SH_OK { return 0 }
91 if v >= NX_TLS13_RECV_SH_VERDICT_N { return 0 }
92 return 1
93}
94
95// Step 3c.2: consume ServerHello + derive handshake keys.
96func nx_tls13_client_session_recv_sh(
97 s: *Tls13ClientSession,
98 sh_bytes: *u8, sh_len: i64
99) -> i64 {
100 if s.state != NX_TLS13_CSESSION_STATE_CH_SENT {
101 return NX_TLS13_RECV_SH_BAD_STATE
102 }
103
104 // ---- Parse ServerHello structure ----
105 let p_lv: *i64 = sys_mmap(16) as *i64
106 let p_ro: *i64 = sys_mmap(16) as *i64
107 let p_cs: *i64 = sys_mmap(16) as *i64
108 let p_eo: *i64 = sys_mmap(16) as *i64
109 let p_el: *i64 = sys_mmap(16) as *i64
110 let parse_v: i64 = tls13_server_hello_parse(
111 sh_bytes, sh_len, p_lv, p_ro, p_cs, p_eo, p_el
112 )
113 if parse_v != NX_TLS13_HELLO_VERDICT_OK {
114 return NX_TLS13_RECV_SH_BAD_FORMAT
115 }
116
117 // ---- Accept the server's chosen cipher_suite iff it is one we
118 // OFFERED and can decrypt: ChaCha20-Poly1305 (0x1303) or
119 // AES-128-GCM (0x1301) -- BOTH are fully wired in the record
120 // layer (nx_tls13_record dispatches per s.cipher_suite).
121 // [Corrected 2026-07-03: previously claimed AES-128-GCM
122 // "dispatch is not wired" -- stale; it is live + decrypts.]
123 // Accept either advertised cipher: AES-128-GCM (0x1301) or
124 // ChaCha20-Poly1305 (0x1303). Record layer dispatches via
125 // tls13_record_aead_seal/open per s.cipher_suite.
126 var cipher_ok: i64 = 0
127 if *p_cs == NX_TLS13_CS_CHACHA20_POLY1305_SHA256 { cipher_ok = 1 }
128 if *p_cs == NX_TLS13_CS_AES_128_GCM_SHA256 { cipher_ok = 1 }
129 // R9 (2026-08-05): 0x1302 AES-256-GCM-SHA384 is now REAL, not just advertised. It is
130 // nginx's DEFAULT preference, so refusing it made a large slice of the web unreachable --
131 // and the crawler then retired those hosts, laundering our capability gap into permanent
132 // coverage loss. Record layer: nx_tls13_record dispatches to nx_aes256_gcm (gate 4/4).
133 // Transcript: dual-hash from byte one (gate 4/4). Schedule/HKDF were ALREADY hash-
134 // parameterized. ★A CLIENTHELLO IS A PROMISE -- now we can keep the one we were making.
135 if *p_cs == NX_TLS13_CS_AES_256_GCM_SHA384 { cipher_ok = 1 }
136 // R9b: record the server's CHOICE BEFORE judging it. A refusal that cannot name the suite it
137 // refused forces every caller's diagnostic to GUESS -- and the guess ("it must be 0x1302")
138 // outlived its truth and sent a session hunting a cipher the server may never have chosen.
139 s.cipher_suite = *p_cs
140 if cipher_ok != 1 {
141 // Split the refusal by CAUSE. Every TLS 1.3 suite is 0x13xx; anything else means the server
142 // answered our 1.3 hello with a 1.2 negotiation, which no client-side cipher work can change.
143 if (*p_cs / 256) != 0x13 { return NX_TLS13_RECV_SH_NOT_TLS13 }
144 return NX_TLS13_RECV_SH_BAD_CIPHER
145 }
146
147 // ---- Find key_share extension ----
148 let p_ks_off: *i64 = sys_mmap(16) as *i64
149 let p_ks_len: *i64 = sys_mmap(16) as *i64
150 let find_v: i64 = tls13_ext_find(
151 sh_bytes + *p_eo, *p_el,
152 EXT_KEY_SHARE,
153 p_ks_off, p_ks_len
154 )
155 if find_v != NX_TLS13_HELLO_VERDICT_OK {
156 return NX_TLS13_RECV_SH_NO_KEYSHARE
157 }
158
159 // ---- Parse key_share entry: group(2) + ke_len(2) + ke ----
160 // In SH the key_share is a SINGLE KeyShareEntry (RFC 8446 §4.2.8.2)
161 let ks_data_off: i64 = *p_eo + *p_ks_off
162 if *p_ks_len < 4 { return NX_TLS13_RECV_SH_BAD_FORMAT }
163 let group: i64 = ((sh_bytes[ks_data_off] & 0xff) << 8)
164 | (sh_bytes[ks_data_off + 1] & 0xff)
165 var group_ok: i64 = 0
166 if group == NX_TLS13_GROUP_X25519 { group_ok = 1 }
167 if group == NX_TLS13_GROUP_SECP256R1 { group_ok = 1 }
168 if group_ok != 1 {
169 return NX_TLS13_RECV_SH_BAD_GROUP
170 }
171 let ke_len: i64 = ((sh_bytes[ks_data_off + 2] & 0xff) << 8)
172 | (sh_bytes[ks_data_off + 3] & 0xff)
173 if 4 + ke_len > *p_ks_len { return NX_TLS13_RECV_SH_BAD_FORMAT }
174
175 // ---- Compute shared secret per the server's chosen group ----
176 // (B4: the CH carries both an x25519 and a secp256r1 share, so
177 // either branch completes without a HelloRetryRequest.)
178 let shared: *u8 = sys_mmap(32)
179 var i: i64 = 0
180 if group == NX_TLS13_GROUP_X25519 {
181 if ke_len != 32 {
182 return NX_TLS13_RECV_SH_BAD_KEYSHARE_LEN
183 }
184 // Copy server X25519 pubkey into session.
185 while i < 32 {
186 s.server_x25519_pub[i] = sh_bytes[ks_data_off + 4 + i]
187 i = i + 1
188 }
189 let xv: i64 = x25519(s.x25519_priv, s.server_x25519_pub, shared)
190 if xv != 0 { return NX_TLS13_RECV_SH_INTERNAL }
191 }
192 if group == NX_TLS13_GROUP_SECP256R1 {
193 if ke_len != 65 {
194 return NX_TLS13_RECV_SH_BAD_KEYSHARE_LEN
195 }
196 // Off-curve / malformed server share is peer data -> BAD_FORMAT;
197 // a bad own scalar would be INTERNAL.
198 let pv: i64 = p256_ecdh_shared(
199 s.p256_priv, sh_bytes + ks_data_off + 4, 65, shared
200 )
201 if pv == NX_P256_ECDH_BAD_POINT { return NX_TLS13_RECV_SH_BAD_FORMAT }
202 if pv != NX_P256_ECDH_OK { return NX_TLS13_RECV_SH_INTERNAL }
203 }
204
205 // ---- TLS 1.3 key schedule cascade ----
206 let zeros: *u8 = sys_mmap(64)
207 // R9: the key schedule width IS the suite's hash. Buffers are 64 (not 32) so the 48-byte
208 // SHA-384 secrets fit -- a 32-byte buffer taking a 48-byte write is the silent-corruption
209 // shape this estate has been bitten by before.
210 var hash_len: i64 = TLS13_HASH_LEN_SHA256
211 if *p_cs == NX_TLS13_CS_AES_256_GCM_SHA384 { hash_len = 48 }
212
213 // Stage 1: early_secret = HKDF-Extract(0, 0)
214 let early: *u8 = sys_mmap(64)
215 tls13_early_secret(zeros, hash_len, early)
216
217 // Stage 2: derived_1 = Derive-Secret(early, "derived", "") with H_empty
218 // H_empty must be the SUITE'S hash of the empty string, not always SHA-256.
219 let empty_hash: *u8 = sys_mmap(64)
220 if hash_len == 48 { sha384_digest(zeros, 0, empty_hash) } else { sha256_digest(zeros, 0, empty_hash) }
221 let derived_1: *u8 = sys_mmap(64)
222 tls13_derived(early, empty_hash, hash_len, derived_1)
223
224 // Stage 3: handshake_secret = HKDF-Extract(derived_1, shared)
225 tls13_handshake_secret(derived_1, hash_len, shared, 32, s.handshake_secret)
226
227 // ---- Feed ServerHello into transcript so H1 = hash(CH || SH) ----
228 nx_tls13_transcript_update(s.transcript, sh_bytes, sh_len)
229
230 // ---- H1 = snapshot of transcript hash (CH || SH) ----
231 // R9: H1 must be hashed with the SUITE'S transcript hash.
232 let h1: *u8 = sys_mmap(64)
233 if hash_len == 48 { nx_tls13_transcript_snapshot384(s.transcript, h1) } else { nx_tls13_transcript_snapshot(s.transcript, h1) }
234
235 // ---- Derive c/s handshake traffic SECRETS via Derive-Secret ----
236 // Stored in session because both the SF MAC verify (in
237 // dispatcher's WAIT_SF) AND the client_Finished emit (in 3c.4)
238 // need the SECRETS, not just the derived keys. Key updates
239 // during long sessions also need the parent secrets.
240 tls13_traffic_secret(s.handshake_secret, NX_TLS13_LABEL_C_HS_TRAFFIC,
241 h1, hash_len, s.client_hs_traffic_secret)
242 tls13_traffic_secret(s.handshake_secret, NX_TLS13_LABEL_S_HS_TRAFFIC,
243 h1, hash_len, s.server_hs_traffic_secret)
244
245 // ---- Expand each traffic secret into AEAD key + IV ----
246 // Key width tracks the negotiated cipher_suite:
247 // AES-128-GCM-SHA256 (0x1301) -> key_len = 16
248 // ChaCha20-Poly1305-SHA256 (0x1303) -> key_len = 32
249 // Both use iv_len = 12.
250 var key_len: i64 = 32
251 if s.cipher_suite == NX_TLS13_CS_AES_128_GCM_SHA256 { key_len = 16 }
252 tls13_traffic_key(s.client_hs_traffic_secret, key_len, s.client_hs_traffic_key)
253 tls13_traffic_key(s.server_hs_traffic_secret, key_len, s.server_hs_traffic_key)
254 tls13_traffic_iv(s.client_hs_traffic_secret, 12, s.client_hs_iv)
255 tls13_traffic_iv(s.server_hs_traffic_secret, 12, s.server_hs_iv)
256
257 // ---- Reset per-direction record-layer sequence numbers ----
258 s.client_seq = 0
259 s.server_seq = 0
260
261 // ---- Advance state ----
262 s.state = NX_TLS13_CSESSION_STATE_WAIT_EE
263 return NX_TLS13_RECV_SH_OK
264}
265
266// Compile-only smoke. Real KAT in
267// nx_tls13_client_session_recv_sh_test.nx.
268func main() -> i64 {
269 return 0
270}