code wiki / (root) / nx_email_mx.nx

nx_email_mx.nx source

↩ module page · 271 lines · 11486 B

1// nx_email_mx.nx -- EMAIL RUNG R0: MX-record resolution (RFC 5321 §5.1). 2// 3// module: nishi-core.email.mx 4// depends: nishi-core.net.dns 5// capability: CORE_EMAIL 6// 7// The FIRST email-specific rung above the existing sovereign transport 8// hardware (nx_socket UDP + nx_dns wire codec). Before sending mail a 9// client must learn WHICH host accepts mail for a domain: that is the 10// domain's MX (Mail eXchanger) record set, ordered by preference 11// (lowest preference value = highest priority, tried first -- RFC 5321 12// §5.1). nx_dns already encodes the MX qtype (NX_DNS_TYPE_MX=15) and a 13// generic query builder; what was missing is the ANSWER-side parse for 14// MX rdata (preference u16 + exchange domain-name, which may use RFC 15// 1035 §4.1.4 compression pointers) plus the preference ordering. 16// 17// This module is PURE (no syscalls): parse/decode/sort over caller 18// buffers, architecture-neutral exactly like nx_dns.nx, so the live 19// UDP composition can live in a later nx_email_mx_io.nx (mirrors the 20// nx_dns / nx_dns_io split) without dragging a syscall dependency into 21// the codec. Query building is already covered: call the existing 22// nx_dns_build_query(name, len, NX_DNS_TYPE_MX, tx_id, out, cap). 23// 24// license_tier: INDEPENDENT_REDERIVE 25// genealogy_id: international-research-sources/ietf/rfc_1035 + ietf/rfc_5321 26// lineage_id: nishi_email_mx_resolution_r0 27// 28// nx_safety_envelope: 29// intended_use: "MX rdata parse (preference + exchange name) + 30// preference ordering. RFC 5321 §5.1 substrate." 31// sil_target: SIL2 (DNS-spoof picks the attacker's MX) 32// evidence: [RFC_5321_canonical_basis, sealed_verdict_enum, 33// txid_spoof_gate, bounded_128_hop_anti_cycle, 34// compression_pointer_decode, preference_sort] 35// hazard_register: [bug-tape-MX-spoof-relay-hijack, 36// bug-tape-compression-pointer-cycle-OOM] 37// residual_risk: "Authenticity of the MX set needs DNSSEC / 38// DANE in an upstream consumer (queued R5). This 39// substrate enforces the txid spoof gate + a 40// 128-hop bound on compression-pointer chase." 41// verdict: NOT_YET_EVALUATED 42 43import "nx_dns.nx" 44 45// ---- Sealed verdict for an MX parse / decode operation. ---- 46const NX_EMX_OK: i64 = 1 47const NX_EMX_BAD_HEADER: i64 = 2 48const NX_EMX_TXID_MISMATCH: i64 = 3 49const NX_EMX_NOT_RESPONSE: i64 = 4 50const NX_EMX_TRUNCATED: i64 = 5 51const NX_EMX_RCODE_NONZERO: i64 = 6 52const NX_EMX_NO_MX: i64 = 7 53const NX_EMX_BAD_RDATA: i64 = 8 54const NX_EMX_NAMES_FULL: i64 = 9 55const NX_EMX_PTR_LOOP: i64 = 10 56const NX_EMX_BAD_LABEL: i64 = 11 57const NX_EMX_N: i64 = 12 58 59// Bounded compression-pointer budget (anti-cycle, JPL-rule-2). A 60// crafted self-referential exchange pointer terminates in 61// NX_EMX_PTR_LOOP rather than hanging. 62const NX_EMX_MAX_HOPS: i64 = 128 63 64func nx_email_mx_verdict_name(v: i64) -> *u8 { 65 if v == NX_EMX_OK { return "OK" } 66 if v == NX_EMX_BAD_HEADER { return "BAD_HEADER" } 67 if v == NX_EMX_TXID_MISMATCH { return "TXID_MISMATCH" } 68 if v == NX_EMX_NOT_RESPONSE { return "NOT_RESPONSE" } 69 if v == NX_EMX_TRUNCATED { return "TRUNCATED" } 70 if v == NX_EMX_RCODE_NONZERO { return "RCODE_NONZERO" } 71 if v == NX_EMX_NO_MX { return "NO_MX" } 72 if v == NX_EMX_BAD_RDATA { return "BAD_RDATA" } 73 if v == NX_EMX_NAMES_FULL { return "NAMES_FULL" } 74 if v == NX_EMX_PTR_LOOP { return "PTR_LOOP" } 75 if v == NX_EMX_BAD_LABEL { return "BAD_LABEL" } 76 return "UNKNOWN" 77} 78 79func nx_email_mx_verdict_is_valid(v: i64) -> i64 { 80 if v <= 0 { return 0 } 81 if v >= NX_EMX_N { return 0 } 82 return 1 83} 84 85// ---- Decode a wire-format domain name into a dotted ASCII string. ---- 86// 87// Follows RFC 1035 §4.1.4 compression pointers with a bounded hop 88// budget. Writes a null-terminated string into out (needs out_cap >= 89// out_len + 1). Returns the written length (>= 0, EXCLUDING the null) 90// on success, or a NEGATIVE verdict on error. Length 0 = the root 91// name "." (RFC 7505 "null MX" => domain accepts no mail). 92// 93// Unlike nx_dns_skip_name (which only advances the outer cursor), this 94// MATERIALISES the name -- needed because an MX exchange host is the 95// connect target, not a field to be skipped. 96func emx_decode_name(buf: *u8, n: i64, start: i64, out: *u8, out_cap: i64) -> i64 { 97 if start < 0 { return 0 - NX_EMX_BAD_RDATA } 98 if start >= n { return 0 - NX_EMX_BAD_RDATA } 99 if out_cap <= 1 { return 0 - NX_EMX_NAMES_FULL } 100 var cur: i64 = start 101 var oi: i64 = 0 102 var hops: i64 = 0 103 var first: i64 = 1 104 var done: i64 = 0 105 var verr: i64 = 0 106 while done == 0 && verr == 0 { 107 if hops > NX_EMX_MAX_HOPS { 108 verr = NX_EMX_PTR_LOOP 109 } else { 110 if cur >= n { 111 verr = NX_EMX_BAD_RDATA 112 } else { 113 let b: i64 = buf[cur] & 0xff 114 if b == 0 { 115 cur = cur + 1 116 done = 1 117 } else { 118 let high2: i64 = b & 0xc0 119 if high2 == 0xc0 { 120 if cur + 1 >= n { 121 verr = NX_EMX_BAD_RDATA 122 } else { 123 let lo: i64 = buf[cur + 1] & 0xff 124 let tgt: i64 = ((b & 0x3f) << 8) | lo 125 if tgt >= n { 126 verr = NX_EMX_BAD_RDATA 127 } else { 128 cur = tgt 129 hops = hops + 1 130 } 131 } 132 } else { 133 if high2 != 0 { 134 verr = NX_EMX_BAD_LABEL 135 } else { 136 if b > 63 { 137 verr = NX_EMX_BAD_LABEL 138 } else { 139 if cur + 1 + b > n { 140 verr = NX_EMX_BAD_RDATA 141 } else { 142 if first == 0 { 143 if oi + 1 > out_cap - 1 { 144 verr = NX_EMX_NAMES_FULL 145 } else { 146 out[oi] = 46 as u8 147 oi = oi + 1 148 } 149 } 150 if verr == 0 { 151 first = 0 152 if oi + b > out_cap - 1 { 153 verr = NX_EMX_NAMES_FULL 154 } else { 155 var k: i64 = 0 156 while k < b { 157 out[oi + k] = buf[cur + 1 + k] 158 k = k + 1 159 } 160 oi = oi + b 161 cur = cur + 1 + b 162 } 163 } 164 } 165 } 166 } 167 } 168 } 169 } 170 } 171 } 172 if verr != 0 { return 0 - verr } 173 out[oi] = 0 as u8 174 return oi 175} 176 177// ---- Parse a DNS response, collect MX records. ---- 178// 179// Fills prefs[i] (the 16-bit preference) and ex_off[i] (byte offset 180// into the caller's `names` scratch buffer where this record's 181// null-terminated exchange hostname begins). Returns the record COUNT 182// (>= 1) on success, or a NEGATIVE verdict on error. A response that 183// parses cleanly but contains no MX RR returns -NX_EMX_NO_MX. 184// 185// expected_tx_id MUST match the query's tx_id: a mismatch is the 186// primary off-path spoof gate (RFC 5452) -- without it an attacker's 187// forged response could redirect a domain's mail to a relay they 188// control. 189func nx_email_mx_parse( 190 buf: *u8, n: i64, 191 expected_tx_id: i64, 192 prefs: *i64, ex_off: *i64, max_recs: i64, 193 names: *u8, names_cap: i64 194) -> i64 { 195 if n < NX_DNS_HEADER_LEN { return 0 - NX_EMX_BAD_HEADER } 196 let tx: i64 = nx_dns_get_u16_be(buf, 0) 197 if tx != (expected_tx_id & 0xffff) { return 0 - NX_EMX_TXID_MISMATCH } 198 let flags: i64 = nx_dns_get_u16_be(buf, 2) 199 if (flags & NX_DNS_FLAG_QR) == 0 { return 0 - NX_EMX_NOT_RESPONSE } 200 if (flags & NX_DNS_FLAG_TC) != 0 { return 0 - NX_EMX_TRUNCATED } 201 let rcode: i64 = flags & NX_DNS_RCODE_MASK 202 if rcode != 0 { return 0 - NX_EMX_RCODE_NONZERO } 203 let qdcount: i64 = nx_dns_get_u16_be(buf, 4) 204 let ancount: i64 = nx_dns_get_u16_be(buf, 6) 205 if qdcount > NX_DNS_MAX_RECORDS { return 0 - NX_EMX_BAD_HEADER } 206 if ancount > NX_DNS_MAX_RECORDS { return 0 - NX_EMX_BAD_HEADER } 207 208 var o: i64 = NX_DNS_HEADER_LEN 209 var qi: i64 = 0 210 while qi < qdcount { 211 let after_q: i64 = nx_dns_skip_name(buf, n, o) 212 if after_q < 0 { return 0 - NX_EMX_BAD_HEADER } 213 if after_q + 4 > n { return 0 - NX_EMX_BAD_HEADER } 214 o = after_q + 4 215 qi = qi + 1 216 } 217 218 var count: i64 = 0 219 var ncur: i64 = 0 220 var ai: i64 = 0 221 while ai < ancount { 222 let after_n: i64 = nx_dns_skip_name(buf, n, o) 223 if after_n < 0 { return 0 - NX_EMX_BAD_HEADER } 224 if after_n + 10 > n { return 0 - NX_EMX_BAD_HEADER } 225 let rtype: i64 = nx_dns_get_u16_be(buf, after_n) 226 let rclass: i64 = nx_dns_get_u16_be(buf, after_n + 2) 227 let rdlength: i64 = nx_dns_get_u16_be(buf, after_n + 8) 228 let rdata_off: i64 = after_n + 10 229 if rdata_off + rdlength > n { return 0 - NX_EMX_BAD_HEADER } 230 if rtype == NX_DNS_TYPE_MX && rclass == NX_DNS_CLASS_IN && rdlength >= 3 { 231 if count < max_recs { 232 let pref: i64 = nx_dns_get_u16_be(buf, rdata_off) 233 let dlen: i64 = emx_decode_name(buf, n, rdata_off + 2, names + ncur, names_cap - ncur) 234 if dlen < 0 { return dlen } 235 prefs[count] = pref 236 ex_off[count] = ncur 237 ncur = ncur + dlen + 1 238 count = count + 1 239 } 240 } 241 o = rdata_off + rdlength 242 ai = ai + 1 243 } 244 if count == 0 { return 0 - NX_EMX_NO_MX } 245 return count 246} 247 248// ---- Order MX records by preference, lowest first (RFC 5321 §5.1). ---- 249// 250// Stable insertion sort moving prefs[] and ex_off[] in lockstep. The 251// client tries prefs[0]'s exchange first; equal-preference records may 252// be tried in any order (RFC 5321 leaves it to the sender). Insertion 253// sort is exact, in place, allocation-free, and fast for the small N 254// (typically < 8) an MX set holds. 255func nx_email_mx_sort(prefs: *i64, ex_off: *i64, count: i64) -> i64 { 256 var i: i64 = 1 257 while i < count { 258 let kp: i64 = prefs[i] 259 let ke: i64 = ex_off[i] 260 var j: i64 = i - 1 261 while j >= 0 && prefs[j] > kp { 262 prefs[j + 1] = prefs[j] 263 ex_off[j + 1] = ex_off[j] 264 j = j - 1 265 } 266 prefs[j + 1] = kp 267 ex_off[j + 1] = ke 268 i = i + 1 269 } 270 return 0 271}