code wiki / (root) / nx_x509_san.nx

nx_x509_san.nx source

↩ module page · 350 lines · 14375 B

1// nx_x509_san.nx -- X.509 Subject Alternative Name walk + hostname 2// match (RFC 5280 §4.2.1.6 + RFC 6125 §6.4). 3// 4// Phase 0b §I.2.C of the Nishi TLS 1.3 stack. Composes the 5// X509Cert.extensions_off/_len captured by nx_x509.x509_parse 6// (commit c45b19e7) into the SAN-dNSName hostname-match check 7// that any real Web PKI cert chain must pass. 8// 9// ASN.1 structure (RFC 5280 §4.2.1.6): 10// 11// Extensions ::= SEQUENCE OF Extension (outer at extensions_off) 12// Extension ::= SEQUENCE { 13// extnID OBJECT IDENTIFIER, -- 2.5.29.17 for SAN 14// critical BOOLEAN DEFAULT FALSE, 15// extnValue OCTET STRING -- DER-encoded SAN 16// } 17// SubjectAltName ::= GeneralNames (lives inside extnValue) 18// GeneralNames ::= SEQUENCE OF GeneralName 19// GeneralName ::= CHOICE { -- [2] tag is dNSName 20// otherName [0] OtherName, 21// rfc822Name [1] IA5String, 22// dNSName [2] IA5String, 23// x400Address [3] ORAddress, 24// directoryName [4] Name, 25// ediPartyName [5] EDIPartyName, 26// uniformResourceIdentifier [6] IA5String, 27// iPAddress [7] OCTET STRING, 28// registeredID [8] OBJECT IDENTIFIER 29// } 30// 31// SAN OID 2.5.29.17 encoded as DER: 06 03 55 1D 11 (tag 0x06, len 3, 32// content 0x55 0x1D 0x11). 33// 34// dNSName context-tag byte: [2] IMPLICIT IA5String = 0x82. 35// 36// Hostname matching rules (RFC 6125 §6.4): 37// 38// 1. Exact case-insensitive match of whole hostname -> MATCH. 39// 2. Wildcard "*" allowed ONLY as left-most label: 40// pattern = "*.example.com" 41// host = "foo.example.com" -> MATCH 42// host = "foo.bar.example.com" -> NO MATCH (only one label 43// consumed by *) 44// host = "example.com" -> NO MATCH (no left-most label) 45// 3. Wildcard mid-label or in non-leftmost label is REJECTED as 46// malformed. 47// 4. Comparisons are case-insensitive for ASCII; non-ASCII labels 48// treated as literal bytes (no IDNA normalisation -- that's 49// caller responsibility per RFC 6125 §6.4.2). 50// 51// Per Cardinal 12 (defensive at boundaries): all reads are 52// bounded against extensions_len; malformed input returns the 53// appropriate verdict, never reads past the cert buffer. 54// 55// What this primitive does NOT do (single-responsibility): 56// - IDNA / Punycode normalisation (caller pre-normalises if 57// internationalised hostnames are in scope). 58// - IP-address SAN matching (separate primitive, queued). 59// - URI SAN matching (separate primitive, queued). 60// - subjectName CN fallback (NOT compliant with RFC 6125 §6.4.4 61// for new certs; deliberately not supported). 62// 63// license_tier: INDEPENDENT_REDERIVE 64// genealogy_id: international-research-sources/ietf/rfc_5280 + rfc_6125 65// lineage_id: nishi_x509_san_q10 66 67// nx_safety_envelope: 68// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 69// sil_target: SIL1 70// evidence: [bulk_applied_2026-05-19, rfc-6125-hostname-match] 71// verdict: NOT_YET_EVALUATED 72 73import "nx_syscalls.nx" 74import "nx_asn1.nx" 75import "nx_x509.nx" 76 77// --- Sealed verdict enum ------------------------------------------- 78 79const NX_X509_SAN_OK: i64 = 1 // host matched a SAN dNSName entry 80const NX_X509_SAN_NO_MATCH: i64 = 2 // SAN present, no dNSName matched 81const NX_X509_SAN_NO_EXTENSIONS: i64 = 3 // cert has no v3 extensions block 82const NX_X509_SAN_NO_SAN: i64 = 4 // extensions present, no SAN OID 83const NX_X509_SAN_BAD_FORMAT: i64 = 5 // malformed extension data 84const NX_X509_SAN_BAD_PATTERN: i64 = 6 // SAN entry has illegal wildcard placement 85const NX_X509_SAN_VERDICT_N: i64 = 7 86 87func nx_x509_san_verdict_is_valid(v: i64) -> i64 { 88 if v < NX_X509_SAN_OK { return 0 } 89 if v >= NX_X509_SAN_VERDICT_N { return 0 } 90 return 1 91} 92 93// --- ASCII case-insensitive byte compare --------------------------- 94 95func ascii_to_lower(b: i64) -> i64 { 96 if b >= 0x41 { 97 if b <= 0x5A { 98 return b + 0x20 99 } 100 } 101 return b 102} 103 104// Compare two byte ranges case-insensitively. Returns 1 on 105// equal, 0 on differ. 106func bytes_eq_ci(a: *u8, alen: i64, b: *u8, blen: i64) -> i64 { 107 if alen != blen { return 0 } 108 var i: i64 = 0 109 while i < alen { 110 let ax: i64 = ascii_to_lower(a[i] & 0xff) 111 let bx: i64 = ascii_to_lower(b[i] & 0xff) 112 if ax != bx { return 0 } 113 i = i + 1 114 } 115 return 1 116} 117 118// Count occurrences of byte `needle` in [buf, buf+len). 119func count_byte(buf: *u8, len: i64, needle: i64) -> i64 { 120 var i: i64 = 0 121 var n: i64 = 0 122 while i < len { 123 if (buf[i] & 0xff) == needle { n = n + 1 } 124 i = i + 1 125 } 126 return n 127} 128 129// --- Hostname wildcard match (RFC 6125 §6.4) ---------------------- 130// 131// pattern the SAN dNSName bytes ("*.example.com" etc.) 132// plen pattern length 133// host caller-supplied SNI hostname bytes ("foo.example.com") 134// hlen hostname length 135// 136// Returns: 137// NX_X509_SAN_OK on case-insensitive match (exact or 138// leftmost-label wildcard) 139// NX_X509_SAN_NO_MATCH mismatch but pattern is well-formed 140// NX_X509_SAN_BAD_PATTERN wildcard not at leftmost label or 141// multiple wildcards 142// 143// Wildcard placement rules: 144// - exactly 0 wildcards: must equal host exactly 145// - exactly 1 wildcard: must be pattern[0] and pattern[1] = '.'; 146// pattern label "*" matches exactly ONE host label 147// (leftmost), and rest must equal host's tail after the 148// first '.'. 149func hostname_wildcard_match(pattern: *u8, plen: i64, 150 host: *u8, hlen: i64) -> i64 { 151 if plen <= 0 { return NX_X509_SAN_NO_MATCH } 152 if hlen <= 0 { return NX_X509_SAN_NO_MATCH } 153 154 let nstars: i64 = count_byte(pattern, plen, 0x2A) // '*' 155 if nstars == 0 { 156 if bytes_eq_ci(pattern, plen, host, hlen) == 1 { 157 return NX_X509_SAN_OK 158 } 159 return NX_X509_SAN_NO_MATCH 160 } 161 if nstars >= 2 { return NX_X509_SAN_BAD_PATTERN } 162 163 // Exactly one '*' -- must be pattern[0] and pattern[1] must be '.'. 164 if pattern[0] != 0x2A { return NX_X509_SAN_BAD_PATTERN } 165 if plen < 3 { return NX_X509_SAN_BAD_PATTERN } 166 if pattern[1] != 0x2E { return NX_X509_SAN_BAD_PATTERN } // '.' 167 168 // Find host's first '.' -- it terminates the leftmost label. 169 var dot: i64 = 0 - 1 170 var i: i64 = 0 171 while i < hlen { 172 if host[i] == 0x2E { dot = i; i = hlen } else { i = i + 1 } 173 } 174 if dot < 0 { return NX_X509_SAN_NO_MATCH } // host has no '.', cannot match wildcard pattern 175 if dot == 0 { return NX_X509_SAN_NO_MATCH } // host starts with '.', degenerate 176 177 // Compare pattern[1..plen] (which starts with '.') against 178 // host[dot..hlen] (which also starts with '.'). Lengths must 179 // match exactly. 180 let suffix_len: i64 = plen - 1 181 let host_suffix_len: i64 = hlen - dot 182 if suffix_len != host_suffix_len { return NX_X509_SAN_NO_MATCH } 183 if bytes_eq_ci(pattern + 1, suffix_len, host + dot, host_suffix_len) == 1 { 184 return NX_X509_SAN_OK 185 } 186 return NX_X509_SAN_NO_MATCH 187} 188 189// --- SAN extension lookup ------------------------------------------ 190// 191// Walk the Extensions SEQUENCE inside cert.extensions_off/_len, 192// find the Extension whose extnID is 2.5.29.17 (SubjectAltName), 193// and return the offset+length of its extnValue inner content (the 194// SEQUENCE OF GeneralName body, NOT the wrapping OCTET STRING). 195// 196// Returns: 197// NX_X509_SAN_OK *out_off / *out_len populated 198// NX_X509_SAN_NO_EXTENSIONS extensions_off == -1 199// NX_X509_SAN_NO_SAN extensions present but no SAN OID 200// NX_X509_SAN_BAD_FORMAT extension SEQUENCE walk failed 201// 202// SAN OID DER bytes: 06 03 55 1D 11 (tag, len, content). 203func x509_san_locate(buf: *u8, cert: *X509Cert, 204 out_off: *i64, out_len: *i64) -> i64 { 205 if cert.extensions_off < 0 { return NX_X509_SAN_NO_EXTENSIONS } 206 if cert.extensions_len <= 0 { return NX_X509_SAN_NO_EXTENSIONS } 207 208 // The extensions_off points at the body of [3] EXPLICIT, which 209 // contains a single outer SEQUENCE wrapping the list. Step 210 // into that outer SEQUENCE. 211 let c_raw: *u8 = sys_mmap(32) 212 let c: *Asn1Cursor = c_raw as *Asn1Cursor 213 c.pos = cert.extensions_off 214 c.end = cert.extensions_off + cert.extensions_len 215 216 let outer_len_p: *i64 = sys_mmap(16) as *i64 217 let rc_outer: i64 = asn1_expect_tag(buf, c, ASN1_SEQUENCE, outer_len_p) 218 if rc_outer < 0 { return NX_X509_SAN_BAD_FORMAT } 219 let list_end: i64 = c.pos + *outer_len_p 220 if list_end > c.end { return NX_X509_SAN_BAD_FORMAT } 221 222 // Iterate Extension SEQUENCEs. 223 while c.pos < list_end { 224 let ext_len_p: *i64 = sys_mmap(16) as *i64 225 let rc_ext: i64 = asn1_expect_tag(buf, c, ASN1_SEQUENCE, ext_len_p) 226 if rc_ext < 0 { return NX_X509_SAN_BAD_FORMAT } 227 let ext_end: i64 = c.pos + *ext_len_p 228 if ext_end > list_end { return NX_X509_SAN_BAD_FORMAT } 229 230 // Read extnID OID. 231 let oid_len_p: *i64 = sys_mmap(16) as *i64 232 let rc_oid: i64 = asn1_expect_tag(buf, c, ASN1_OID, oid_len_p) 233 if rc_oid < 0 { return NX_X509_SAN_BAD_FORMAT } 234 let oid_off: i64 = c.pos 235 let oid_len: i64 = *oid_len_p 236 c.pos = c.pos + oid_len 237 238 // SAN OID has content bytes 0x55 0x1D 0x11 (length 3). 239 var is_san: i64 = 0 240 if oid_len == 3 { 241 if (buf[oid_off] & 0xff) == 0x55 { 242 if (buf[oid_off+1] & 0xff) == 0x1D { 243 if (buf[oid_off+2] & 0xff) == 0x11 { 244 is_san = 1 245 } 246 } 247 } 248 } 249 250 // Optional critical BOOLEAN (tag 0x01). Skip if present. 251 if c.pos < ext_end { 252 if (buf[c.pos] & 0xff) == 0x01 { 253 let crit_len_p: *i64 = sys_mmap(16) as *i64 254 let rc_crit: i64 = asn1_expect_tag(buf, c, 0x01, crit_len_p) 255 if rc_crit < 0 { return NX_X509_SAN_BAD_FORMAT } 256 c.pos = c.pos + *crit_len_p 257 } 258 } 259 260 // extnValue OCTET STRING -- the encapsulating layer. 261 let val_len_p: *i64 = sys_mmap(16) as *i64 262 let rc_val: i64 = asn1_expect_tag(buf, c, ASN1_OCTET_STRING, val_len_p) 263 if rc_val < 0 { return NX_X509_SAN_BAD_FORMAT } 264 265 if is_san == 1 { 266 // Step inside the OCTET STRING: SubjectAltName is a 267 // SEQUENCE OF GeneralName. Return the body of that 268 // SEQUENCE so the caller's GeneralName walk lands at 269 // the first entry. 270 let inner_pos: i64 = c.pos 271 let inner_end: i64 = c.pos + *val_len_p 272 let san_seq_len_p: *i64 = sys_mmap(16) as *i64 273 let inner_c_raw: *u8 = sys_mmap(32) 274 let inner_c: *Asn1Cursor = inner_c_raw as *Asn1Cursor 275 inner_c.pos = inner_pos 276 inner_c.end = inner_end 277 let rc_san: i64 = asn1_expect_tag(buf, inner_c, ASN1_SEQUENCE, san_seq_len_p) 278 if rc_san < 0 { return NX_X509_SAN_BAD_FORMAT } 279 *out_off = inner_c.pos 280 *out_len = *san_seq_len_p 281 return NX_X509_SAN_OK 282 } 283 284 // Skip the extnValue body and advance to the next Extension. 285 c.pos = c.pos + *val_len_p 286 if c.pos != ext_end { return NX_X509_SAN_BAD_FORMAT } 287 } 288 return NX_X509_SAN_NO_SAN 289} 290 291// --- Public API: match hostname against SAN dNSName entries ------- 292// 293// Find the SAN extension, walk every GeneralName CHOICE entry, 294// and try wildcard_match for each dNSName ([2] = 0x82) until one 295// matches or all are exhausted. 296// 297// Returns: 298// NX_X509_SAN_OK host matched a dNSName entry 299// NX_X509_SAN_NO_MATCH SAN present, no dNSName matched 300// NX_X509_SAN_NO_EXTENSIONS cert has no extensions block 301// NX_X509_SAN_NO_SAN extensions present but no SAN 302// NX_X509_SAN_BAD_FORMAT malformed extension data 303// NX_X509_SAN_BAD_PATTERN a SAN entry has illegal wildcard 304// (returned only if NO other entry 305// matched first) 306func x509_san_match_hostname(buf: *u8, cert: *X509Cert, 307 host: *u8, host_len: i64) -> i64 { 308 let san_off_p: *i64 = sys_mmap(16) as *i64 309 let san_len_p: *i64 = sys_mmap(16) as *i64 310 let locate: i64 = x509_san_locate(buf, cert, san_off_p, san_len_p) 311 if locate != NX_X509_SAN_OK { return locate } 312 313 let san_off: i64 = *san_off_p 314 let san_len: i64 = *san_len_p 315 let san_end: i64 = san_off + san_len 316 317 var pos: i64 = san_off 318 var saw_bad_pattern: i64 = 0 319 while pos < san_end { 320 if pos + 2 > san_end { return NX_X509_SAN_BAD_FORMAT } 321 let tag: i64 = buf[pos] & 0xff 322 let inner_len: i64 = buf[pos + 1] & 0xff 323 // dNSName uses short-form length (single byte) since names 324 // are < 256 bytes; long form is treated as malformed for 325 // safety (no real CA emits dNSName > 255 chars). 326 if (inner_len & 0x80) != 0 { return NX_X509_SAN_BAD_FORMAT } 327 let entry_end: i64 = pos + 2 + inner_len 328 if entry_end > san_end { return NX_X509_SAN_BAD_FORMAT } 329 330 if tag == 0x82 { 331 // dNSName: try wildcard match. 332 let pat: *u8 = buf + pos + 2 333 let m: i64 = hostname_wildcard_match(pat, inner_len, host, host_len) 334 if m == NX_X509_SAN_OK { return NX_X509_SAN_OK } 335 if m == NX_X509_SAN_BAD_PATTERN { saw_bad_pattern = 1 } 336 // else NX_X509_SAN_NO_MATCH -- try next entry 337 } 338 // Skip non-dNSName entries (rfc822Name, URI, IP, etc.) -- those 339 // are validated by separate primitives. 340 pos = entry_end 341 } 342 if pos != san_end { return NX_X509_SAN_BAD_FORMAT } 343 if saw_bad_pattern == 1 { return NX_X509_SAN_BAD_PATTERN } 344 return NX_X509_SAN_NO_MATCH 345} 346 347// Compile-only smoke. Real KAT in nx_x509_san_test.nx. 348func main() -> i64 { 349 return 0 350}