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}