nx_tls13_transcript.nx source
↩ module page · 218 lines · 9880 B
1// nx_tls13_transcript.nx -- TLS 1.3 transcript hash (RFC 8446 §4.4.1).
2//
3// Phase 0b §E of the Nishi TLS 1.3 stack per
4// docs/NISHI_TLS13_GAP_AUDIT.md. Running SHA-256 over the
5// concatenation of every handshake message in send/receive order,
6// with the HelloRetryRequest synthetic-message replacement special
7// case. Every Derive-Secret call in the schedule and the Finished
8// MAC's HMAC(transcript_hash) all read from this state.
9//
10// The HRR rule:
11// When the server responds to ClientHello with HelloRetryRequest,
12// the value of ClientHello1 in the transcript is REPLACED with a
13// special synthetic handshake message of type message_hash(254)
14// containing Hash(ClientHello1). Every subsequent Derive-Secret
15// computation sees this replaced prefix, not the original CH1.
16//
17// This exists so that the handshake transcript stays
18// ~constant-size on resumption regardless of how large the original
19// ClientHello was (the original CH1 might carry an arbitrarily
20// large pre_shared_key extension).
21//
22// What it does today:
23// - init / update / snapshot (non-destructive get)
24// - HRR replace via synthetic-message-hash construction
25// - sealed verdict + validity gate
26//
27// What it doesn't do yet:
28// - SHA-384 variant (queued; composes against nx_sha512 / nx_sha384
29// if/when that's wired)
30// - Multi-buffer message append (caller can call update() multiple
31// times instead; covered by streaming SHA-256 already)
32//
33// KAT verified:
34// - empty transcript -> SHA-256("") = e3b0c4...b855
35// - 3-message concatenation matches sha256_digest of the same bytes
36// - snapshot non-destructive (subsequent update changes hash)
37// - HRR replace produces transcript whose hash equals
38// SHA-256(synthetic_message_hash_record)
39//
40// Composes with:
41// - nx_sha256 (streaming context + finalize)
42// - nx_tls13_schedule (consumes snapshot at Derive-Secret call sites)
43// - nx_tls13_client state machine (queued; calls update() at every
44// handshake message send/receive)
45//
46// license_tier: INDEPENDENT_REDERIVE
47// genealogy_id: international-research-sources/ietf/rfc_8446
48// lineage_id: nishi_tls13_transcript_q10
49
50// nx_safety_envelope:
51// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
52// sil_target: SIL1
53// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
54// verdict: NOT_YET_EVALUATED
55
56import "nx_syscalls.nx"
57import "nx_sha256.nx"
58import "nx_sha512.nx" // R9: sha384_init/sha512_update/sha512_final -- the 0x1302 transcript
59
60// Sha256 ctx is 18 i64 fields = 144 bytes; we round to 256 to match
61// nx_sha256's own internal allocation pattern and leave headroom.
62const NX_TLS13_TRANSCRIPT_CTX_BYTES: i64 = 256
63const NX_TLS13_HASH_LEN_SHA256: i64 = 32
64
65// HandshakeType for the HRR-synthetic record (RFC 8446 §B.3).
66const NX_TLS13_HS_TYPE_MESSAGE_HASH: i64 = 254
67
68// Sealed verdict.
69const NX_TLS13_TX_VERDICT_OK: i64 = 1
70const NX_TLS13_TX_VERDICT_N: i64 = 2
71
72// Allocate + initialise a transcript hash state. Returns a pointer
73// to the freshly-zeroed Sha256 context; caller treats as opaque
74// state and passes to update/snapshot/replace_with_hrr.
75func nx_tls13_transcript_new() -> *u8 {
76 // R9 (2026-08-05): run BOTH transcript hashes from the first byte. TLS 1.3 selects the
77 // transcript hash FROM THE CIPHER SUITE, which is not known until ServerHello -- so a
78 // single streaming SHA-256 can never serve 0x1302 (AES-256-GCM-SHA384) retroactively.
79 // Sha256 stays at offset 0 so every existing caller is byte-identical; the SHA-384 half
80 // lives at offset NX_TLS13_TRANSCRIPT_CTX_BYTES. Sha512 is a FLAT struct (26 i64, no
81 // pointer fields), so its clone is a plain byte copy -- none of the bufptr aliasing
82 // hazard documented on the SHA-256 snapshot below.
83 let raw: *u8 = sys_mmap(NX_TLS13_TRANSCRIPT_CTX_BYTES * 2)
84 let ctx: *Sha256 = raw as *Sha256
85 sha256_init(ctx)
86 let ctx384: *Sha512 = ((raw as i64) + NX_TLS13_TRANSCRIPT_CTX_BYTES) as *Sha512
87 sha384_init(ctx384)
88 return raw
89}
90
91// Feed a complete handshake message into the transcript. msg
92// includes the 1-byte HandshakeType + 3-byte uint24 length + body
93// (i.e., the entire serialised Handshake record per RFC 8446 §4).
94//
95// Caller MUST NOT feed partial messages; the spec defines transcript
96// hash over the concatenation of complete handshake messages.
97func nx_tls13_transcript_update(state: *u8, msg: *u8, msg_len: i64) -> i64 {
98 let ctx: *Sha256 = state as *Sha256
99 sha256_update(ctx, msg, msg_len)
100 // R9: feed the SHA-384 half in lockstep. Both must cover the SAME message sequence,
101 // so they are updated together at the single chokepoint rather than by any caller.
102 let ctx384: *Sha512 = ((state as i64) + NX_TLS13_TRANSCRIPT_CTX_BYTES) as *Sha512
103 sha512_update(ctx384, msg, msg_len)
104 return NX_TLS13_TX_VERDICT_OK
105}
106
107// Snapshot: non-destructive get of the current transcript hash.
108// Clones the underlying Sha256 context into a fresh buffer,
109// finalises the clone, writes the 32-byte hash to out_32. Original
110// state is unchanged and can continue receiving updates.
111//
112// CRITICAL (2026-06-13 root-cause fix, TLS-FIN / X-B1-FIN-001): the
113// Sha256 struct holds POINTER fields (bufptr/kptr/wptr -> mmap'd
114// scratch allocated once in sha256_init). A flat 256-byte byte-copy
115// duplicates the pointer VALUES, not the buffers -- so the clone would
116// SHARE state's 64-byte partial-block buffer. sha256_final on the
117// clone writes 0x80 + zero-padding + length into that block; in the
118// two-block path (idx > 56) it also zeroes bytes [0..55]. Sharing
119// bufptr therefore CLOBBERS the live transcript's partial block, so
120// every subsequent transcript_update compresses corrupted bytes and
121// the Finished MAC diverges -- but ONLY when a snapshot happens to be
122// taken while idx is in the lethal {57..63} range, which is a function
123// of the cumulative handshake byte-count (cert-chain size). That is
124// exactly the size-dependent "self-cancelling divergence" that made
125// real servers (google/example, big chains) fail while loopback and
126// small chains (anthropic) passed. Fix: give the clone its OWN
127// partial-block buffer (deep copy) so finalize never touches the live
128// scratch. kptr (read-only K constants) and wptr (message schedule,
129// fully recomputed every compress) are safe to share.
130// R9: SHA-384 transcript snapshot -- the half TLS 1.3 needs for cipher suite 0x1302.
131// Non-destructive, like its SHA-256 twin. Sha512 is FLAT (26 i64, no pointer fields), so a
132// plain byte copy IS a true clone: sha512_final mutates only the copy. That is why this
133// needs none of the bufptr deep-copy machinery the SHA-256 path documents below.
134// out_48 receives the 48-byte SHA-384 digest (SHA-512 truncated, per FIPS 180-4).
135func nx_tls13_transcript_snapshot384(state: *u8, out_48: *u8) -> i64 {
136 let src: *u8 = ((state as i64) + NX_TLS13_TRANSCRIPT_CTX_BYTES) as *u8
137 let clone_raw: *u8 = sys_mmap(NX_TLS13_TRANSCRIPT_CTX_BYTES)
138 var ci: i64 = 0
139 while ci < NX_TLS13_TRANSCRIPT_CTX_BYTES { clone_raw[ci] = src[ci]; ci = ci + 1 }
140 let clone: *Sha512 = clone_raw as *Sha512
141 let full: *u8 = sys_mmap(64)
142 sha512_final(clone, full)
143 ci = 0
144 while ci < 48 { out_48[ci] = full[ci]; ci = ci + 1 }
145 return NX_TLS13_TX_VERDICT_OK
146}
147
148func nx_tls13_transcript_snapshot(state: *u8, out_32: *u8) -> i64 {
149 let clone_raw: *u8 = sys_mmap(NX_TLS13_TRANSCRIPT_CTX_BYTES)
150 var i: i64 = 0
151 while i < NX_TLS13_TRANSCRIPT_CTX_BYTES {
152 clone_raw[i] = state[i]
153 i = i + 1
154 }
155 let clone_ctx: *Sha256 = clone_raw as *Sha256
156 let src_ctx: *Sha256 = state as *Sha256
157 // Deep-copy the 64-byte partial-block buffer into fresh scratch the
158 // clone owns exclusively; finalize mutates this copy, not state's.
159 let new_buf: *u8 = sys_mmap(64)
160 let old_buf: *u8 = src_ctx.bufptr as *u8
161 i = 0
162 while i < 64 {
163 new_buf[i] = old_buf[i]
164 i = i + 1
165 }
166 clone_ctx.bufptr = new_buf as i64
167 sha256_final(clone_ctx, out_32)
168 return NX_TLS13_TX_VERDICT_OK
169}
170
171// HelloRetryRequest replacement per RFC 8446 §4.4.1.
172//
173// MUST be called AFTER ClientHello1 has been update()'d in and
174// BEFORE the actual ServerHello (HRR) record is processed.
175// Replaces the transcript state with: empty || synthetic_message_hash
176// record, where the synthetic record body is the 32-byte hash of
177// ClientHello1.
178//
179// Synthetic message wire format (RFC 8446 §4.4.1):
180// struct {
181// HandshakeType msg_type = 254; /* 1 byte */
182// uint24 length = Hash.length; /* 3 bytes */
183// opaque hash[Hash.length]; /* 32 byte */
184// } MessageHash;
185func nx_tls13_transcript_replace_with_hrr(state: *u8) -> i64 {
186 // Step 1: snapshot the current hash (which IS Hash(CH1)
187 // since only CH1 has been fed in).
188 let ch1_hash: *u8 = sys_mmap(64)
189 nx_tls13_transcript_snapshot(state, ch1_hash)
190
191 // Step 2: build the 36-byte synthetic record.
192 let synth: *u8 = sys_mmap(64)
193 synth[0] = NX_TLS13_HS_TYPE_MESSAGE_HASH & 0xff
194 synth[1] = 0
195 synth[2] = 0
196 synth[3] = NX_TLS13_HASH_LEN_SHA256 & 0xff
197 var i: i64 = 0
198 while i < NX_TLS13_HASH_LEN_SHA256 {
199 synth[4 + i] = ch1_hash[i]
200 i = i + 1
201 }
202
203 // Step 3: re-init the state (wiping CH1), then feed the
204 // synthetic record. From the schedule's POV, the transcript
205 // now starts with the synthetic record.
206 let ctx: *Sha256 = state as *Sha256
207 sha256_init(ctx)
208 sha256_update(ctx, synth, 4 + NX_TLS13_HASH_LEN_SHA256)
209
210 return NX_TLS13_TX_VERDICT_OK
211}
212
213// Sealed-enum validity gate.
214func nx_tls13_tx_verdict_is_valid(v: i64) -> i64 {
215 if v < 0 { return 0 }
216 if v >= NX_TLS13_TX_VERDICT_N { return 0 }
217 return 1
218}