code wiki / (root) / nx_tls13_transcript.nx

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}