nx_x509_chain_verify.nx source
↩ module page · 236 lines · 10716 B
1// nx_x509_chain_verify.nx -- walk an X.509 cert chain leaf-to-root.
2//
3// Phase 0b §I.4 piece 7 of the chain-walker arc. THE primitive
4// that turns the substrate's single-cert verify (verify_under_issuer,
5// commit 927d9d47) into a real Web PKI chain validator.
6//
7// Composes:
8// nx_x509_verify_under_issuer.nx -- per-cert sig verify
9// nx_x509_dn_match.nx -- DN byte-equality
10// nx_x509_leaf_check.nx (caller) -- separate; caller invokes
11// first for leaf cert's
12// validity + SAN.
13//
14// Chain model: caller prepares an array of parsed X509Cert
15// pointers + their backing buffers, in order from leaf to root:
16// certs[0] = leaf cert (server identity)
17// certs[1] = first intermediate (signed leaf)
18// certs[..] = more intermediates
19// certs[n-1] = trust anchor (root CA, treated as self-signed for
20// the chain-walker purpose -- caller
21// has separately confirmed it lives in
22// the trusted root set)
23//
24// For each adjacent (child, parent) pair (i, i+1):
25// 1. DN linkage: child.issuer must byte-equal parent.subject
26// 2. Sig verify: child's outer sig verifies under parent's pubkey
27//
28// The final cert (trust anchor) is NOT verified against anything
29// further -- the caller's responsibility to ensure it's actually
30// in the trusted root set. RFC 5280 §6.1 calls this the "trust
31// anchor input" to path validation.
32//
33// Public API:
34// nx_x509_chain_verify(bufs, certs, n) -> verdict
35// bufs[i] = *u8 to cert[i]'s DER buffer
36// certs[i] = *X509Cert parsed from bufs[i]
37// n = number of certs in the chain (>=1)
38// nx_x509_chain_verdict_is_valid(v) -> 0|1
39//
40// Sealed verdict enum:
41// NX_X509_CHAIN_OK every link verified
42// NX_X509_CHAIN_EMPTY n < 1
43// NX_X509_CHAIN_TOO_LONG n > NX_X509_CHAIN_MAX_LEN
44// NX_X509_CHAIN_DN_MISMATCH child.issuer != parent.subject
45// at some link
46// NX_X509_CHAIN_SIG_FAIL verify_under_issuer returned
47// non-OK at some link
48//
49// What this primitive does NOT do (caller responsibility):
50// - Leaf cert's validity period + SAN-hostname match
51// (caller runs nx_x509_leaf_check FIRST)
52// - Trust-anchor lookup against a root CA store (caller
53// prepares the ordered chain ending in a confirmed root)
54// - Path-length constraint checks (basicConstraints,
55// pathLenConstraint extension)
56// - keyUsage / extendedKeyUsage / certificatePolicies enforcement
57// - Revocation (OCSP / CRL)
58// - Name constraints
59// These are separate primitives queued for the path-validation
60// completion arc.
61//
62// Per Cardinals 9 (single-responsibility -- this primitive does
63// JUST link-by-link chain walk; trust-anchor lookup + leaf-check
64// + revocation are separate), 12 (defensive at boundaries -- max
65// chain length cap), 22 (composition -- 2 substrate primitives
66// compose into one walker), 23 (preamble names every caller-
67// responsibility item).
68//
69// license_tier: INDEPENDENT_REDERIVE
70// genealogy_id: international-research-sources/ietf/rfc_5280
71// lineage_id: nishi_x509_chain_verify_q10
72
73// nx_safety_envelope:
74// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
75// sil_target: SIL1
76// evidence: [bulk_applied_2026-05-19, x509-chain-walker]
77// verdict: NOT_YET_EVALUATED
78
79import "nx_syscalls.nx"
80import "nx_x509.nx"
81import "nx_x509_dn_match.nx"
82import "nx_x509_verify_under_issuer.nx"
83import "nx_x509_sig_alg.nx"
84import "nx_u384.nx"
85import "nx_sha256.nx"
86const NX_MAGIC_8192: i64 = 8192
87
88static _CV_PN_BUF: i64
89func _cv_pn(v: i64) -> i64 {
90 if _CV_PN_BUF == 0 { _CV_PN_BUF = sys_mmap(24) as i64 } // certloop leak fix: alloc the print buf ONCE
91 let b: *u8 = _CV_PN_BUF as *u8
92 var x: i64 = v
93 if x < 0 { x = 0 - x }
94 var i: i64 = 22
95 if x == 0 { b[i] = 0x30 as u8; i = i - 1 }
96 else { while x > 0 { b[i] = (0x30 + (x - (x/10)*10)) as u8; x = x / 10; i = i - 1 } }
97 sys_write(2, ((b as i64) + i + 1) as *u8, 22 - i)
98 return 0
99}
100
101const NX_X509_CHAIN_OK: i64 = 1
102const NX_X509_CHAIN_EMPTY: i64 = 2
103const NX_X509_CHAIN_TOO_LONG: i64 = 3
104const NX_X509_CHAIN_DN_MISMATCH: i64 = 4
105const NX_X509_CHAIN_SIG_FAIL: i64 = 5
106const NX_X509_CHAIN_VERDICT_N: i64 = 6
107
108const NX_X509_CHAIN_MAX_LEN: i64 = 16
109
110func nx_x509_chain_verdict_is_valid(v: i64) -> i64 {
111 if v < NX_X509_CHAIN_OK { return 0 }
112 if v >= NX_X509_CHAIN_VERDICT_N { return 0 }
113 return 1
114}
115
116// ---- MEMOIZATION CACHE (SOTA TLS speedup, F102c/F108f) --------------------------------------------------------
117// nx_x509_verify_under_issuer is a PURE FUNCTION of the child+parent DER bytes (no time/state -- validity/expiry
118// are checked separately by the caller's leaf_check, NOT here). So memoizing it on sha256(child_der || parent_der)
119// is PROVABLY SAFE: a different/forged cert = different bytes = different hash = cache MISS = full verify. A hit can
120// only occur for a (child,parent) pair whose EXACT bytes were verified before. THE WIN: Let's Encrypt's ECDSA-P-384
121// intermediate+root (E5/E6 + ISRG Root X2) are byte-identical across every LE-issued host, so their ~273ms P-384
122// verify is computed ONCE per process then served from cache (~0ms). In-process static only -- no cross-process or
123// persisted state, no poisoning surface. Gated: nx_x509_chain_cache_gate (hit==recompute, tamper==miss==reject,
124// distinct chains never cross-hit).
125const CV_CACHE_N: i64 = 128
126static _CV_KEYS: i64 // mmap CV_CACHE_N*32 : sha256 keys
127static _CV_RES: i64 // mmap CV_CACHE_N*8 : i64 memoized results
128static _CV_VAL: i64 // mmap CV_CACHE_N : 1=slot occupied
129static _CV_NEXT: i64 // round-robin insert cursor
130static _CV_HITS: i64 // diagnostics
131// DER total length (header+content) of a SEQUENCE; 0 if not parseable -> caller falls through to full verify (safe).
132func _cv_der_len(buf: *u8) -> i64 {
133 if (buf[0] & 0xff) != 48 { return 0 } // 0x30 = SEQUENCE
134 let l0: i64 = buf[1] & 0xff
135 if l0 < 128 { return 2 + l0 } // short-form length
136 let nlen: i64 = l0 - 128 // long-form: nlen length octets
137 if nlen <= 0 { return 0 }
138 if nlen > 4 { return 0 }
139 var len: i64 = 0
140 var k: i64 = 0
141 while k < nlen { len = (len << 8) | (buf[2 + k] & 0xff); k = k + 1 }
142 return 2 + nlen + len
143}
144func _cv_key_eq(keys: *u8, off: i64, key: *u8) -> i64 {
145 var i: i64 = 0
146 while i < 32 { if (keys[off + i] & 0xff) != (key[i] & 0xff) { return 0 } i = i + 1 }
147 return 1
148}
149// memoized verify: same result as nx_x509_verify_under_issuer, served from cache when the exact bytes recur.
150func _cv_verify_memo(child_buf: *u8, child: *X509Cert, parent_buf: *u8, parent: *X509Cert) -> i64 {
151 let clen: i64 = _cv_der_len(child_buf)
152 let plen: i64 = _cv_der_len(parent_buf)
153 if clen <= 0 { return nx_x509_verify_under_issuer(child_buf, child, parent_buf, parent) }
154 if plen <= 0 { return nx_x509_verify_under_issuer(child_buf, child, parent_buf, parent) }
155 if clen + plen > NX_MAGIC_8192 { return nx_x509_verify_under_issuer(child_buf, child, parent_buf, parent) }
156 let tmp: *u8 = sys_mmap(NX_MAGIC_8192)
157 var o: i64 = 0
158 var a: i64 = 0
159 while a < clen { tmp[o] = child_buf[a]; o = o + 1; a = a + 1 }
160 var b: i64 = 0
161 while b < plen { tmp[o] = parent_buf[b]; o = o + 1; b = b + 1 }
162 let key: *u8 = sys_mmap(32)
163 sha256_digest(tmp, o, key)
164 if _CV_KEYS == 0 {
165 _CV_KEYS = sys_mmap(CV_CACHE_N * 32) as i64
166 _CV_RES = sys_mmap(CV_CACHE_N * 8) as i64
167 _CV_VAL = sys_mmap(CV_CACHE_N) as i64
168 _CV_NEXT = 0
169 }
170 let keys: *u8 = _CV_KEYS as *u8
171 let res: *i64 = _CV_RES as *i64
172 let val: *u8 = _CV_VAL as *u8
173 var e: i64 = 0
174 while e < CV_CACHE_N {
175 if (val[e] & 0xff) == 1 { if _cv_key_eq(keys, e * 32, key) == 1 { _CV_HITS = _CV_HITS + 1; return res[e] } }
176 e = e + 1
177 }
178 let v: i64 = nx_x509_verify_under_issuer(child_buf, child, parent_buf, parent)
179 let slot: i64 = _CV_NEXT
180 var c: i64 = 0
181 while c < 32 { keys[slot * 32 + c] = key[c]; c = c + 1 }
182 res[slot] = v
183 val[slot] = 1 as u8
184 _CV_NEXT = slot + 1
185 if _CV_NEXT >= CV_CACHE_N { _CV_NEXT = 0 }
186 return v
187}
188
189// Walk a parsed cert chain from leaf (index 0) to trust anchor
190// (index n-1). For each adjacent (child, parent) pair, verify
191// DN linkage + signature. Single-cert chains are accepted (n=1,
192// implies the only cert IS the trust anchor; caller's
193// responsibility to confirm).
194//
195// bufs: array of *u8 (DER buffers, one per cert)
196// certs: array of *X509Cert (already parsed against the matching buf)
197func nx_x509_chain_verify(bufs: **u8, certs: **X509Cert, n: i64) -> i64 {
198 if n < 1 { return NX_X509_CHAIN_EMPTY }
199 if n > NX_X509_CHAIN_MAX_LEN { return NX_X509_CHAIN_TOO_LONG }
200
201 // Walk adjacent pairs. For n=1 this loop body runs 0 times
202 // and we return OK -- single-cert "chain" is trivially valid.
203 var i: i64 = 0
204 while i < n - 1 {
205 let _sav: i64 = nx_u384_scratch_save() // certloop leak fix: reclaim this link's verify scratch each iter
206 let child_buf: *u8 = bufs[i]
207 let child: *X509Cert = certs[i]
208 let parent_buf: *u8 = bufs[i + 1]
209 let parent: *X509Cert = certs[i + 1]
210
211 // ---- DN linkage: child.issuer == parent.subject ----
212 let dn_ok: i64 = nx_x509_dn_match(child_buf,
213 child.issuer_off, child.issuer_len,
214 parent_buf,
215 parent.subject_off, parent.subject_len)
216 if dn_ok != 1 { nx_u384_scratch_restore(_sav); return NX_X509_CHAIN_DN_MISMATCH }
217
218 // ---- Sig verify: child's outer sig under parent's pubkey ----
219 let _alg: i64 = nx_x509_sig_alg_identify(child_buf, child.sig_alg_off, child.sig_alg_len)
220 let _t0: i64 = sys_now_ms()
221 let v: i64 = _cv_verify_memo(child_buf, child, parent_buf, parent)
222 let _t1: i64 = sys_now_ms()
223 sys_write(2, "nishi-cert link alg=" as *u8, 20); _cv_pn(_alg)
224 sys_write(2, " ms=" as *u8, 4); _cv_pn(_t1 - _t0); sys_write(2, "\n" as *u8, 1)
225 if v != NX_X509_VUI_OK { nx_u384_scratch_restore(_sav); return NX_X509_CHAIN_SIG_FAIL }
226
227 nx_u384_scratch_restore(_sav)
228 i = i + 1
229 }
230 return NX_X509_CHAIN_OK
231}
232
233// Compile-only smoke. Real KAT in nx_x509_chain_verify_test.nx.
234func main() -> i64 {
235 return 0
236}