nx_acme_http.nx source
↩ module page · 362 lines · 16688 B
1// nx_acme_http.nx -- reusable ACME transport + tiny JSON string reader.
2//
3// One ACME request = fresh TLS 1.3 connection (LE supports keep-alive but
4// one-shot connections are simplest + robust): url_for_fetch -> url_connect
5// -> session_run (validates the chain via the trust store) -> get/post_
6// complete -> raw response bytes. The caller parses status/headers/body
7// with nx_http_response_parse + nx_http_response_header + nx_acme_json_str.
8//
9// Composes the proven live-HTTPS stack (same path as the example.com smoke,
10// now reaching LE after the RSA-4096 chain-verify fix).
11//
12// license_tier: ORIGINAL (composes the shipped TLS client + HTTP parser)
13
14import "nx_syscalls.nx"
15import "nx_x509_trust_store.nx"
16import "nx_trust_store_load_from_certdata.nx"
17import "nx_tls13_client_validate_certificate.nx"
18import "nx_tls13_client_session_run.nx"
19import "nx_tls12_client_session.nx" // TLS 1.2 fallback session (api.porkbun.com is TLS-1.2-max)
20import "nx_https_url_for_fetch.nx"
21import "nx_https_url_connect.nx"
22import "nx_https_get_complete.nx"
23import "nx_https_post_complete.nx"
24import "nx_http_client.nx" // request builders reused for the 1.2 round trip
25import "nx_http_response_parse.nx"
26import "nx_csprng.nx"
27const NX_MAGIC_8192: i64 = 8192
28const NX_MAGIC_20000: i64 = 20000
29const NX_MAGIC_4194304: i64 = 4194304
30const NX_MAGIC_1779284141: i64 = 1779284141
31const NX_MAGIC_65536: i64 = 65536
32const NX_MAGIC_16384: i64 = 16384
33
34const NX_ACME_HTTP_OK: i64 = 0
35const NX_ACME_HTTP_URL_FAIL: i64 = 0 - 1
36const NX_ACME_HTTP_CONNECT_FAIL: i64 = 0 - 2
37const NX_ACME_HTTP_TLS_FAIL: i64 = 0 - 3
38const NX_ACME_HTTP_IO_FAIL: i64 = 0 - 4
39
40// TLS 1.2 fallback reconnect-retry budget (defense-in-depth for the rapid
41// 1.3->1.2 reconnect). A transient incomplete first flight (-READ_FLIGHT) or
42// a garbled ServerHello (-UNSUPPORTED_CIPHER) FAILS CLOSED (no session); we
43// self-heal by closing + reconnecting + re-handshaking with FRESH entropy.
44// 3 tries covers a transient that clears within a couple hundred ms; a real
45// outage still terminates (bounded loop, never hangs). Tunable here, not
46// buried in the loop body (no magic numbers).
47const NX_ACME_TLS12_MAX_TRIES: i64 = 3
48const NX_ACME_TLS12_RETRY_SETTLE_MS: i64 = 150
49
50// --- TLS 1.2 fallback round trip ---------------------------------------
51// Carry ONE HTTP request/response over an ALREADY-established TLS 1.2
52// session (api.porkbun.com negotiates TLS 1.2 MAXIMUM, so the 1.3 path
53// gets a version-negotiation alert and we retry over 1.2 here). Builds the
54// SAME HTTP/1.1 request the 1.3 helpers build, sends it as one encrypted
55// application_data record, and drains the encrypted response (app-data
56// accumulated; a TLS alert / close_notify / EOF ends it) into out_buf.
57// Returns POSITIVE bytes accumulated, or NEGATIVE -NX_ACME_HTTP_IO_FAIL.
58func _acme_http12_roundtrip(
59 s: *Tls12ClientSession, fd: i64,
60 post: i64,
61 path: *u8, path_len: i64,
62 host: *u8, host_len: i64,
63 content_type: *u8, content_type_len: i64,
64 body: *u8, body_len: i64,
65 out_buf: *u8, out_cap: i64
66) -> i64 {
67 // Build the request via the shipped HTTP/1.1 request builders.
68 let req: *u8 = sys_mmap(body_len + NX_MAGIC_8192)
69 var req_len: i64 = 0
70 if post == 1 {
71 req_len = nx_http_client_build_request_post(
72 path, path_len, host, host_len,
73 content_type, content_type_len, body, body_len, req)
74 } else {
75 req_len = nx_http_client_build_request(path, path_len, host, host_len, req)
76 }
77 if req_len <= 0 { return 0 - NX_ACME_HTTP_IO_FAIL }
78
79 // Send as one encrypted application_data record (client seq starts at 1).
80 if nx_tls12_session_send(s, fd, req, req_len) != 0 { return 0 - NX_ACME_HTTP_IO_FAIL }
81
82 // Drain encrypted response records until alert / close_notify / EOF.
83 let pt: *u8 = sys_mmap(NX_MAGIC_20000)
84 let ctp: *i64 = sys_mmap(16) as *i64
85 var acc: i64 = 0
86 var rounds: i64 = 0
87 var draining: i64 = 1
88 while draining == 1 {
89 if rounds >= 256 { draining = 0 }
90 else {
91 let pl: i64 = nx_tls12_session_recv(s, fd, pt, NX_MAGIC_20000, ctp)
92 rounds = rounds + 1
93 if pl < 0 { draining = 0 } // EOF / decrypt error -> stop
94 else {
95 if ctp[0] == 23 { // application_data -> accumulate
96 var j: i64 = 0
97 while j < pl {
98 if acc < out_cap { out_buf[acc] = pt[j]; acc = acc + 1 }
99 j = j + 1
100 }
101 }
102 if ctp[0] == 21 { draining = 0 } // alert (e.g. close_notify) -> stop
103 }
104 }
105 }
106 return acc
107}
108
109// Perform one ACME/HTTPS request over a fresh validated TLS session, with a
110// TLS 1.3 -> 1.2 version FALLBACK so the SAME transport reaches BOTH 1.3
111// hosts (e.g. acme-v02.api.letsencrypt.org) AND TLS-1.2-MAXIMUM hosts (e.g.
112// api.porkbun.com):
113// 1. Connect + try the shipped TLS 1.3 client. If it CONNECTS, run the
114// 1.3 GET/POST helper unchanged (no regression for Let's Encrypt).
115// 2. If 1.3 fails SPECIFICALLY at version negotiation -- the server is not
116// 1.3, so its first record is an alert/close_notify and reading or
117// parsing the ServerHello fails (-READ_SH_FAIL / -RECV_SH_FAIL) -- the
118// host is TLS-1.2-max: close the fd, reconnect TCP, and retry the
119// sovereign TLS 1.2 client, carrying the request over the 1.2 session.
120// Any OTHER 1.3 failure (cert/finished/decrypt/etc.) is a genuine error
121// returned as -TLS_FAIL; we do NOT mask it with a 1.2 retry.
122// post==1 -> POST with content_type + body; else GET.
123// Raw response (headers+body) lands in resp_buf; *out_total = byte count.
124// Handshake ephemerals (client_random + key-share scalar) are filled from
125// getrandom for EACH attempt so every connection has independent forward
126// secrecy (never hardcoded).
127func nx_acme_req(url: *u8, url_len: i64,
128 post: i64,
129 content_type: *u8, content_type_len: i64,
130 body: *u8, body_len: i64,
131 store: *TrustStore, now: i64,
132 resp_buf: *u8, resp_cap: i64, out_total: *i64) -> i64 {
133 let url_p: *NxUrl = nx_url_new()
134 let target_raw: *u8 = sys_mmap(32)
135 let target: *NxHttpsTarget = target_raw as *NxHttpsTarget
136 target.url = url_p
137 target.port = 0
138 if nx_https_url_for_fetch(url, target) != NX_HTTPS_URL_OK { return NX_ACME_HTTP_URL_FAIL }
139
140 let host: *u8 = (url as i64 + target.url.host_off) as *u8
141 let host_len: i64 = target.url.host_len
142 let path: *u8 = (url as i64 + target.url.path_off) as *u8
143 let path_len: i64 = target.url.path_len
144
145 // Validation context (trust store + SNI host + now) -- shared by both attempts.
146 let vc_raw: *u8 = sys_mmap(64)
147 let vc: *TlsValidationContext = vc_raw as *TlsValidationContext
148 vc.store = store
149 vc.sni_host = host
150 vc.sni_host_len = host_len
151 vc.now_epoch = now
152
153 // ---- Attempt 1: TLS 1.3 (the existing, proven Let's Encrypt path) ----
154 let fd_p: *i64 = sys_mmap(16) as *i64
155 if nx_https_url_connect(target, url, now, fd_p) != NX_HTTPS_CONNECT_OK { return NX_ACME_HTTP_CONNECT_FAIL }
156 var fd: i64 = *fd_p
157
158 // fresh ephemeral handshake material (forward secrecy per connection)
159 let cr: *u8 = sys_mmap(32)
160 let priv: *u8 = sys_mmap(32)
161 nx_csprng_fill(cr, 32)
162 nx_csprng_fill(priv, 32)
163
164 let sr: i64 = nx_tls13_client_session_run(fd, host, host_len, cr, priv, vc)
165 if sr > 0 {
166 let session: *Tls13ClientSession = sr as *Tls13ClientSession
167 var total: i64 = 0
168 if post == 1 {
169 total = nx_https_post_complete(session, fd,
170 path, path_len, host, host_len,
171 content_type, content_type_len,
172 body, body_len,
173 resp_buf, resp_cap)
174 } else {
175 total = nx_https_get_complete(session, fd,
176 path, path_len, host, host_len,
177 resp_buf, resp_cap)
178 }
179 sys_close(fd)
180 if total < 0 { return NX_ACME_HTTP_IO_FAIL }
181 out_total[0] = total
182 return NX_ACME_HTTP_OK
183 }
184
185 // 1.3 did not connect. Is this a VERSION-negotiation failure (=> retry
186 // 1.2) or a genuine error (=> fail closed)? The first-record alert /
187 // ServerHello-read/parse failure is the only "wrong TLS version" symptom.
188 // GRACEFUL teardown of the 1.3 socket (shutdown THEN close) so the
189 // 1.3->1.2 transition starts from clean TCP state (rung 1: clean fd state
190 // on reconnect). A bare close while the server's unread Certificate/SKE/
191 // ServerHelloDone are still buffered aborts the connection with an RST
192 // that races the immediately-following 1.2 SYN.
193 sys_shutdown(fd, 2)
194 sys_close(fd)
195 var version_nego_fail: i64 = 0
196 if sr == 0 - NX_TLS13_RUN_RECV_SH_FAIL { version_nego_fail = 1 }
197 if sr == 0 - NX_TLS13_RUN_READ_SH_FAIL { version_nego_fail = 1 }
198 if version_nego_fail == 0 { return NX_ACME_HTTP_TLS_FAIL }
199
200 // ---- Attempt 2: TLS 1.2 fallback with bounded reconnect-RETRY ----
201 // Each try is a FRESH TCP connection + FRESH handshake entropy (forward
202 // secrecy preserved per attempt; a half-open socket is never reused). A
203 // FAIL-CLOSED handshake on rapid reconnect -- incomplete first flight
204 // (-READ_FLIGHT), a garbled/unsupported ServerHello (-UNSUPPORTED_CIPHER),
205 // or a record error -- is treated as a TRANSIENT: shutdown+close, brief
206 // settle, reconnect, re-handshake. The loop is BOUNDED so a real outage
207 // terminates (never hangs); on success we leave the loop with fd12/sr12 live.
208 var sr12: i64 = 0
209 var fd12: i64 = 0 - 1
210 var ok12: i64 = 0
211 var tries: i64 = 0
212 while tries < NX_ACME_TLS12_MAX_TRIES {
213 tries = tries + 1
214 if nx_https_url_connect(target, url, now, fd_p) != NX_HTTPS_CONNECT_OK {
215 if tries < NX_ACME_TLS12_MAX_TRIES { sys_sleep_ms(NX_ACME_TLS12_RETRY_SETTLE_MS) }
216 } else {
217 fd12 = *fd_p
218 sys_set_socket_timeout(fd12, 15) // 1.2 cert chain is larger -> generous handshake budget
219 let cr2: *u8 = sys_mmap(32)
220 let seed2: *u8 = sys_mmap(32)
221 nx_csprng_fill(cr2, 32)
222 nx_csprng_fill(seed2, 32)
223 let r12: i64 = nx_tls12_client_session_run(fd12, host, host_len, cr2, seed2, vc)
224 if r12 > 0 {
225 sr12 = r12
226 ok12 = 1
227 tries = NX_ACME_TLS12_MAX_TRIES // success -> leave the retry loop
228 } else {
229 sys_shutdown(fd12, 2)
230 sys_close(fd12)
231 fd12 = 0 - 1
232 if tries < NX_ACME_TLS12_MAX_TRIES { sys_sleep_ms(NX_ACME_TLS12_RETRY_SETTLE_MS) }
233 }
234 }
235 }
236 if ok12 == 0 {
237 if fd12 >= 0 { sys_close(fd12) }
238 return NX_ACME_HTTP_TLS_FAIL
239 }
240
241 let s12: *Tls12ClientSession = sr12 as *Tls12ClientSession
242 let total12: i64 = _acme_http12_roundtrip(s12, fd12, post,
243 path, path_len, host, host_len,
244 content_type, content_type_len,
245 body, body_len,
246 resp_buf, resp_cap)
247 sys_close(fd12)
248 if total12 < 0 { return NX_ACME_HTTP_IO_FAIL }
249 out_total[0] = total12
250 return NX_ACME_HTTP_OK
251}
252
253// Find the FIRST occurrence of `"key":"<value>"` within buf[start..start+n]
254// and return the value's byte range. Tolerant of whitespace around the
255// colon. ACME top-level keys are unique, and ACME string values contain
256// no escaped quotes (URLs/tokens), so a plain scan to the next '"' is safe.
257// Returns 1 if found (out_off/out_len set), 0 otherwise.
258func nx_acme_json_str(buf: *u8, start: i64, n: i64,
259 key: *u8, key_len: i64,
260 out_off: *i64, out_len: *i64) -> i64 {
261 let end: i64 = start + n
262 var i: i64 = start
263 while i < end {
264 if buf[i] == 0x22 { // opening quote of a key
265 var m: i64 = 1
266 var k: i64 = 0
267 while k < key_len {
268 if (i + 1 + k) >= end { m = 0; break }
269 if buf[i + 1 + k] != key[k] { m = 0; break }
270 k = k + 1
271 }
272 if m == 1 {
273 if (i + 1 + key_len) < end {
274 if buf[i + 1 + key_len] == 0x22 { // closing quote of key
275 var colon: i64 = 0 - 1
276 var c: i64 = i + 2 + key_len
277 while c < end {
278 if buf[c] == 0x3a { colon = c; break }
279 c = c + 1
280 }
281 if colon >= 0 {
282 var vq: i64 = 0 - 1
283 var q: i64 = colon + 1
284 while q < end {
285 if buf[q] == 0x22 { vq = q; break }
286 q = q + 1
287 }
288 if vq >= 0 {
289 let vstart: i64 = vq + 1
290 var fe: i64 = 0 - 1
291 var e: i64 = vstart
292 while e < end {
293 if buf[e] == 0x22 { fe = e; break }
294 e = e + 1
295 }
296 if fe >= 0 {
297 out_off[0] = vstart
298 out_len[0] = fe - vstart
299 return 1
300 }
301 }
302 }
303 }
304 }
305 }
306 }
307 i = i + 1
308 }
309 return 0
310}
311
312// ---- KAT: live directory -> newNonce URL -> GET nonce -> Replay-Nonce ----
313func main() -> i64 {
314 let cdpath: *u8 = "/tmp/mozilla_certdata.txt\x00"
315 let lr: i64 = nx_trust_store_load_from_certdata(cdpath, 300, NX_MAGIC_4194304)
316 if lr <= 0 { sys_write(2, "trust load FAIL\n" as *u8, 16); return 1 }
317 let store: *TrustStore = lr as *TrustStore
318
319 let now: i64 = NX_MAGIC_1779284141
320
321 // 1. GET directory
322 let dir_url: *u8 = "https://acme-v02.api.letsencrypt.org/directory\x00"
323 let buf: *u8 = sys_mmap(NX_MAGIC_65536)
324 let tot: *i64 = sys_mmap(8) as *i64
325 if nx_acme_req(dir_url, 46, 0, 0 as *u8, 0, 0 as *u8, 0, store, now, buf, NX_MAGIC_65536, tot) != NX_ACME_HTTP_OK {
326 sys_write(2, "directory req FAIL\n" as *u8, 19); return 2
327 }
328 let r: *i64 = nx_http_resp_alloc()
329 if nx_http_response_parse(buf, tot[0], r) != 0 { sys_write(2, "dir parse FAIL\n" as *u8, 15); return 3 }
330 if r[1] != 200 { sys_write(2, "dir not 200\n" as *u8, 12); return 4 }
331
332 // body region
333 let body_off: i64 = r[6]
334 let body_len: i64 = tot[0] - body_off
335 let voff: *i64 = sys_mmap(8) as *i64
336 let vlen: *i64 = sys_mmap(8) as *i64
337 if nx_acme_json_str(buf, body_off, body_len, "newNonce" as *u8, 8, voff, vlen) != 1 {
338 sys_write(2, "newNonce key not found\n" as *u8, 23); return 5
339 }
340 sys_write(1, "newNonce URL: " as *u8, 14); sys_write(1, buf + voff[0], vlen[0]); sys_write(1, "\n" as *u8, 1)
341
342 // 2. GET newNonce, read Replay-Nonce header
343 let nurl: *u8 = sys_mmap(256)
344 var z: i64 = 0
345 while z < vlen[0] { nurl[z] = buf[voff[0] + z]; z = z + 1 }
346 nurl[vlen[0]] = 0 as u8
347 let buf2: *u8 = sys_mmap(NX_MAGIC_16384)
348 let tot2: *i64 = sys_mmap(8) as *i64
349 if nx_acme_req(nurl, vlen[0], 0, 0 as *u8, 0, 0 as *u8, 0, store, now, buf2, NX_MAGIC_16384, tot2) != NX_ACME_HTTP_OK {
350 sys_write(2, "nonce req FAIL\n" as *u8, 15); return 6
351 }
352 let r2: *i64 = nx_http_resp_alloc()
353 if nx_http_response_parse(buf2, tot2[0], r2) != 0 { sys_write(2, "nonce parse FAIL\n" as *u8, 17); return 7 }
354 let no: *i64 = sys_mmap(8) as *i64
355 let nl: *i64 = sys_mmap(8) as *i64
356 if nx_http_response_header(buf2, tot2[0], r2, "Replay-Nonce" as *u8, 12, no, nl) != 1 {
357 sys_write(2, "Replay-Nonce header missing\n" as *u8, 28); return 8
358 }
359 sys_write(1, "Replay-Nonce: " as *u8, 14); sys_write(1, buf2 + no[0], nl[0]); sys_write(1, "\n" as *u8, 1)
360 sys_write(1, "ACME HTTP layer KAT PASS (directory + nonce, live)\n" as *u8, 51)
361 return 0
362}