code wiki / (root) / nx_acme_http.nx

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}