code wiki / (root) / nx_acme_dns01_issue.nx

nx_acme_dns01_issue.nx source

↩ module page · 683 lines · 35380 B

1// nx_acme_dns01_issue.nx -- live ACME (RFC 8555) DNS-01 *WILDCARD* issuance 2// drive loop (ES256 account key). Renews the *.nishifamily.com + nishifamily.com 3// certificate using Porkbun DNS-01 validation (the ONLY method that can prove 4// control of a wildcard name -- HTTP-01 cannot issue wildcards). 5// 6// COMPOSES (do not rewrite -- reuse): 7// nx_acme_issue.nx -- acme_dup_cstr / acme_account_key / acme_nonce_from / 8// acme_signed_post / acme_json_array_nth (+ the whole 9// ES256 + HTTPS + CSR closure it transitively imports). 10// Its own main() is dropped as a non-root import. 11// nx_acme_es256.nx -- nx_acme_dns01_txt_value (TXT = b64url(SHA256(keyAuth))), 12// payload builders, CSR builder, new-account JWS. 13// nx_acme_porkbun.nx -- nx_porkbun_set_txt / nx_porkbun_delete_txt / 14// pk_readline_file (Porkbun DNS API over sovereign TLS). 15// nx_pem.nx -- pem_decode (PEM cert -> DER), reused per chain block. 16// 17// FLOW: trust -> directory -> newAccount(reuse persistent key) -> newNonce -> 18// newOrder(nishifamily.com + *.nishifamily.com) -> for each authz: 19// find dns-01 -> compute TXT -> Porkbun CREATE _acme-challenge TXT -> 20// [DNS propagation window -- see TODO] -> trigger -> poll valid -> 21// Porkbun DELETE TXT (every exit path) -> generate cert key -> CSR -> 22// finalize -> poll order -> download PEM chain -> PEM->DER concat -> 23// write /tmp/new_le_fullchain.der + /tmp/new_le_ecdsa_key.bin (TEMP ONLY). 24// 25// SAFETY: writes ONLY to /tmp. The live daemon cert paths 26// /volume1/homes/elderwesto/nishihost/certs/le_fullchain.der 27// /volume1/homes/elderwesto/nishihost/certs/le_ecdsa_key.bin 28// are NEVER touched here -- the human reviews the /tmp artifacts then deploys. 29// 30// ENV: default = Let's Encrypt PRODUCTION. Run with argv[1] starting "s" 31// (e.g. "staging") to use the STAGING directory. NOTE: staging chains to 32// the "(STAGING) Pretend Pear X1" / "(STAGING) Doctored Durian Root CA X3" 33// roots, which are NOT in Mozilla's certdata -- the human must add them to 34// /tmp/mozilla_certdata.txt for a staging TLS handshake to validate. 35// 36// license_tier: ORIGINAL (composes RFC 8555 DNS-01 + Porkbun API + the P-256 stack) 37 38import "nx_acme_issue.nx" 39import "nx_acme_porkbun.nx" 40import "nx_pem.nx" 41import "nx_acme_dns01_propagation.nx" // real TXT-propagation poll (replaces the fixed sleep) 42const ACME_MAGIC_16384: i64 = 16384 43const ACME_MAGIC_4194304: i64 = 4194304 44const ACME_MAGIC_2048: i64 = 2048 45const ACME_MAGIC_65536: i64 = 65536 46const ACME_MAGIC_8192: i64 = 8192 47const ACME_MAGIC_3000: i64 = 3000 48const ACME_MAGIC_4096: i64 = 4096 49 50// ---- ACME directory endpoints (single source of truth; no magic URLs) ---- 51const ACME_DIR_PROD: *u8 = "https://acme-v02.api.letsencrypt.org/directory\x00" 52const ACME_DIR_PROD_N: i64 = 46 53const ACME_DIR_STAGING: *u8 = "https://acme-staging-v02.api.letsencrypt.org/directory\x00" 54const ACME_DIR_STAGING_N: i64 = 54 55 56// The Porkbun zone the _acme-challenge TXT records live in. BOTH identifiers 57// (apex nishifamily.com + wildcard *.nishifamily.com) validate via 58// _acme-challenge.nishifamily.com (two TXT records, distinct values). 59const DNS01_DOMAIN: *u8 = "nishifamily.com\x00" 60const DNS01_DOMAIN_N: i64 = 15 61 62// Porkbun creds: read straight from the sovereign vault's decrypt output. The driver runs 63// `nx_secret get porkbun` (-> /tmp/nxsecret.out) immediately before this organ, in the same 64// shell invocation. (line1 = apikey, line2 = secretapikey.) No flat-file secret store. 65const PORKBUN_KEY_PATH: *u8 = "/tmp/nxsecret.out\x00" 66 67// Persistent cert private key (32-byte big-endian P-256 scalar) -- the EXACT 68// format nx_sites_daemon loads as le_ecdsa_key.bin (it rejects len != 32). 69const CERT_KEY_PATH: *u8 = "/tmp/acme_cert_key.bin\x00" 70 71// Outputs -- /tmp ONLY (human reviews then deploys to the live cert paths). 72const OUT_FULLCHAIN_DER: *u8 = "/tmp/new_le_fullchain.der\x00" 73const OUT_ECDSA_KEY: *u8 = "/tmp/new_le_ecdsa_key.bin\x00" 74const OUT_FULLCHAIN_PEM: *u8 = "/tmp/new_nishifamily_fullchain.pem\x00" 75 76// strlen-and-write a NUL-terminated string to a fd (avoids manual length math). 77func d1_pp(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 78func d1_pe(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(2, s, n); return 0 } 79 80// strlen for a NUL-terminated string (length only, no write). 81func d1_strlen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 82// 1 if s contains a '.' -- distinguishes a domain arg ("andelinwest.com") from the staging flag. 83func d1_has_dot(s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { if s[i] == (0x2e as u8) { return 1 } i = i + 1 } return 0 } 84// build "*." + dom into out (NUL-terminated); returns the wildcard length (dom_n + 2). 85func d1_mk_wild(dom: *u8, dom_n: i64, out: *u8) -> i64 { 86 out[0] = 0x2a as u8; out[1] = 0x2e as u8 // "*." 87 var i: i64 = 0 88 while i < dom_n { out[2 + i] = dom[i]; i = i + 1 } 89 out[2 + dom_n] = 0 as u8 90 return dom_n + 2 91} 92// build pre + dom + suf into out (NUL-terminated); returns the byte length. Used for the per-domain 93// /tmp artifact paths so two distinct certs (nishifamily vs andelinwest) never clobber each other. 94func d1_mk_path(pre: *u8, dom: *u8, dom_n: i64, suf: *u8, out: *u8) -> i64 { 95 var w: i64 = 0 96 var i: i64 = 0 97 while pre[i] != (0 as u8) { out[w] = pre[i]; w = w + 1; i = i + 1 } 98 i = 0 99 while i < dom_n { out[w] = dom[i]; w = w + 1; i = i + 1 } 100 i = 0 101 while suf[i] != (0 as u8) { out[w] = suf[i]; w = w + 1; i = i + 1 } 102 out[w] = 0 as u8 103 return w 104} 105 106// Locate the dns-01 challenge object inside an authorization body and extract 107// its token + url (mirrors acme_find_http01 but matches the 6-byte "dns-01"). 108// The challenges array holds {type,url,token,status} objects; we find the one 109// whose type is "dns-01", bound its enclosing {...}, and read token/url within. 110func d1_find_dns01(body: *u8, start: i64, len: i64, 111 tok_off: *i64, tok_len: *i64, 112 url_off: *i64, url_len: *i64) -> i64 { 113 let end: i64 = start + len 114 var p: i64 = 0 - 1 115 var i: i64 = start 116 while i < end - 6 { 117 if body[i] == 0x64 { // 'd' 118 if body[i+1] == 0x6e { // 'n' 119 if body[i+2] == 0x73 { // 's' 120 if body[i+3] == 0x2d { // '-' 121 if body[i+4] == 0x30 { // '0' 122 if body[i+5] == 0x31 { p = i; break } // '1' 123 } 124 } 125 } 126 } 127 } 128 i = i + 1 129 } 130 if p < 0 { return 0 } 131 var obj_start: i64 = p 132 while obj_start > start { 133 if body[obj_start] == 0x7b { break } // '{' 134 obj_start = obj_start - 1 135 } 136 var obj_end: i64 = p 137 while obj_end < end { 138 if body[obj_end] == 0x7d { break } // '}' 139 obj_end = obj_end + 1 140 } 141 let span: i64 = obj_end - obj_start + 1 142 if nx_acme_json_str(body, obj_start, span, "token" as *u8, 5, tok_off, tok_len) != 1 { return 0 } 143 if nx_acme_json_str(body, obj_start, span, "url" as *u8, 3, url_off, url_len) != 1 { return 0 } 144 return 1 145} 146 147// Find the next "-----BEGIN " literal in buf[from..end); -1 if none. Used to 148// iterate a multi-cert PEM chain (each window decodes its first block). 149func d1_find_begin(buf: *u8, end: i64, from: i64) -> i64 { 150 let lit: *u8 = "-----BEGIN " as *u8 151 var p: i64 = from 152 while p + 11 <= end { 153 if buf[p] == 0x2d { 154 var m: i64 = 1 155 var k: i64 = 0 156 while k < 11 { 157 if buf[p + k] != lit[k] { m = 0; break } 158 k = k + 1 159 } 160 if m == 1 { return p } 161 } 162 p = p + 1 163 } 164 return 0 - 1 165} 166 167// Delete ALL _acme-challenge TXT records (idempotent cleanup; called on EVERY 168// post-creation exit path -- success or failure). Result is logged, not fatal. 169func d1_cleanup(ak: *u8, akn: i64, sk: *u8, skn: i64, store: *TrustStore, now: i64, dom: *u8, dom_n: i64) -> i64 { 170 let resp: *u8 = sys_mmap(ACME_MAGIC_16384) 171 let rn: *i64 = sys_mmap(8) as *i64 172 let dv: i64 = nx_porkbun_delete_txt(ak, akn, sk, skn, dom, dom_n, store, now, resp, ACME_MAGIC_16384, rn) 173 if dv == NX_PORKBUN_OK { d1_pp(" cleanup: _acme-challenge TXT records deleted\n" as *u8) } 174 else { d1_pp(" cleanup: TXT delete returned non-OK (continuing anyway)\n" as *u8) } 175 return dv 176} 177 178func main(argc: i64, argv: *i64) -> i64 { 179 // ---- 0. trust store ---- 180 let cdpath: *u8 = "data/mozilla_certdata.txt\x00" 181 let lr: i64 = nx_trust_store_load_from_certdata(cdpath, 300, ACME_MAGIC_4194304) 182 if lr <= 0 { d1_pe("trust load FAIL (data/mozilla_certdata.txt)\n" as *u8); return 1 } 183 let store: *TrustStore = lr as *TrustStore 184 let now: i64 = sys_now_realtime_sec() 185 186 // directory URL: default PRODUCTION; argv[1] starting 's' -> STAGING. 187 var dir_url: *u8 = ACME_DIR_PROD 188 var dir_url_n: i64 = ACME_DIR_PROD_N 189 var staging: i64 = 0 190 if argc > 1 { 191 let a1: *u8 = argv[1] as *u8 192 if a1[0] == (0x73 as u8) { staging = 1 } // 's' 193 } 194 if staging == 1 { 195 dir_url = ACME_DIR_STAGING 196 dir_url_n = ACME_DIR_STAGING_N 197 d1_pp("ACME env: STAGING (acme-staging-v02; needs staging roots in trust store)\n" as *u8) 198 } else { 199 d1_pp("ACME env: PRODUCTION (acme-v02.api.letsencrypt.org)\n" as *u8) 200 } 201 202 // ---- base domain (Porkbun zone + cert CN/SAN base) ---- 203 // Default = nishifamily.com (the proven no-arg path is byte-for-byte unchanged). A DOTTED argv[1] 204 // (e.g. "andelinwest.com") selects a different base domain on the SAME Porkbun account; the wildcard 205 // "*.<dom>" is derived from it. Both nishifamily.com and andelinwest.com are 15 chars / "*." form is 17, 206 // but we strlen rather than assume, so any same-account zone works. (A base domain literally STARTING with 207 // 's' would also trip the staging check above -- not the case for our zones; pass production explicitly if so.) 208 var dom: *u8 = DNS01_DOMAIN 209 var dom_n: i64 = DNS01_DOMAIN_N 210 if argc > 1 { 211 let av1: *u8 = argv[1] as *u8 212 if d1_has_dot(av1) == 1 { dom = av1; dom_n = d1_strlen(av1) } 213 } 214 let wild: *u8 = sys_mmap(256) 215 let wild_n: i64 = d1_mk_wild(dom, dom_n, wild) 216 217 // Per-domain /tmp artifacts. The DEFAULT nishifamily.com keeps the exact legacy names (proven path, 218 // documented artifacts); any other domain gets domain-tagged names so distinct certs never clobber. 219 var out_der: *u8 = OUT_FULLCHAIN_DER 220 var out_key: *u8 = OUT_ECDSA_KEY 221 var out_pem: *u8 = OUT_FULLCHAIN_PEM 222 var ckey_path: *u8 = CERT_KEY_PATH 223 if (dom as i64) != (DNS01_DOMAIN as i64) { 224 let pder: *u8 = sys_mmap(256); d1_mk_path("/tmp/new_" as *u8, dom, dom_n, "_fullchain.der" as *u8, pder); out_der = pder 225 let pkey: *u8 = sys_mmap(256); d1_mk_path("/tmp/new_" as *u8, dom, dom_n, "_ecdsa_key.bin" as *u8, pkey); out_key = pkey 226 let ppem: *u8 = sys_mmap(256); d1_mk_path("/tmp/new_" as *u8, dom, dom_n, "_fullchain.pem" as *u8, ppem); out_pem = ppem 227 let pck: *u8 = sys_mmap(256); d1_mk_path("/tmp/acme_cert_key_" as *u8, dom, dom_n, ".bin" as *u8, pck); ckey_path = pck 228 } 229 d1_pp("ACME base domain (Porkbun zone + CN): " as *u8); sys_write(1, dom, dom_n); d1_pp("\n" as *u8) 230 d1_pp(" cert SAN: " as *u8); sys_write(1, dom, dom_n); d1_pp(" + " as *u8); sys_write(1, wild, wild_n); d1_pp("\n" as *u8) 231 232 // ---- Porkbun creds from /tmp/porkbun.key (line1=apikey, line2=secret) ---- 233 // (Human stages this from the sovereign vault before the run -- see report.) 234 var raw: *u8 = sys_mmap(ACME_MAGIC_2048) 235 var rawn: i64 = pk_readline_file(PORKBUN_KEY_PATH, raw, ACME_MAGIC_2048) 236 if rawn <= 0 { d1_pe("porkbun creds read FAIL -- run `nx_secret get porkbun` first\n" as *u8); return 2 } 237 // Strip a leading UTF-8 BOM (EF BB BF) the vault decrypt may prepend -- left in 238 // place it corrupts the FIRST field (apikey) -> Porkbun "Invalid API key (001)". 239 // (Matches nx_tls12_porkbun_e2e_gate; boundary-validate untrusted file bytes, rule 12.) 240 if rawn >= 3 { if raw[0] == (0xEF as u8) { if raw[1] == (0xBB as u8) { if raw[2] == (0xBF as u8) { 241 raw = (raw as i64 + 3) as *u8; rawn = rawn - 3 242 } } } } 243 var nlp: i64 = 0 244 while nlp < rawn { if raw[nlp] == 0x0a { break } nlp = nlp + 1 } 245 let pk_ak: *u8 = raw 246 var pk_akn: i64 = nlp 247 var s2: i64 = nlp + 1 248 var nl2: i64 = s2 249 while nl2 < rawn { if raw[nl2] == 0x0a { break } nl2 = nl2 + 1 } 250 let pk_sk: *u8 = (raw + s2) as *u8 251 var pk_skn: i64 = nl2 - s2 252 // tolerate trailing CR (CRLF-staged files) at a boundary 253 if pk_akn > 0 { if raw[pk_akn - 1] == 0x0d { pk_akn = pk_akn - 1 } } 254 if pk_skn > 0 { if pk_sk[pk_skn - 1] == 0x0d { pk_skn = pk_skn - 1 } } 255 if pk_akn <= 0 { d1_pe("porkbun apikey empty (line 1)\n" as *u8); return 2 } 256 if pk_skn <= 0 { d1_pe("porkbun secretapikey empty (line 2)\n" as *u8); return 2 } 257 d1_pp("porkbun creds loaded (apikey + secretapikey)\n" as *u8) 258 259 // ---- 1. directory ---- 260 let buf: *u8 = sys_mmap(ACME_MAGIC_65536) 261 let tot: *i64 = sys_mmap(8) as *i64 262 if nx_acme_req(dir_url, dir_url_n, 0, 0 as *u8, 0, 0 as *u8, 0, store, now, buf, ACME_MAGIC_65536, tot) != NX_ACME_HTTP_OK { d1_pe("directory FAIL\n" as *u8); return 3 } 263 let r: *i64 = nx_http_resp_alloc() 264 if nx_http_response_parse(buf, tot[0], r) != 0 { return 3 } 265 if r[1] != 200 { d1_pe("dir != 200\n" as *u8); return 4 } 266 let boff: i64 = r[6] 267 let blen: i64 = tot[0] - boff 268 let vo: *i64 = sys_mmap(8) as *i64 269 let vl: *i64 = sys_mmap(8) as *i64 270 if nx_acme_json_str(buf, boff, blen, "newAccount" as *u8, 10, vo, vl) != 1 { return 5 } 271 let acct_url: *u8 = acme_dup_cstr(buf, vo[0], vl[0]) 272 let acct_url_n: i64 = vl[0] 273 if nx_acme_json_str(buf, boff, blen, "newNonce" as *u8, 8, vo, vl) != 1 { return 6 } 274 let nonce_url: *u8 = acme_dup_cstr(buf, vo[0], vl[0]) 275 let nonce_url_n: i64 = vl[0] 276 if nx_acme_json_str(buf, boff, blen, "newOrder" as *u8, 8, vo, vl) != 1 { return 7 } 277 let order_url: *u8 = acme_dup_cstr(buf, vo[0], vl[0]) 278 let order_url_n: i64 = vl[0] 279 280 // ---- 2. fresh nonce ---- 281 let nb: *u8 = sys_mmap(ACME_MAGIC_16384) 282 let nbt: *i64 = sys_mmap(8) as *i64 283 if nx_acme_req(nonce_url, nonce_url_n, 0, 0 as *u8, 0, 0 as *u8, 0, store, now, nb, ACME_MAGIC_16384, nbt) != NX_ACME_HTTP_OK { return 8 } 284 let nr: *i64 = nx_http_resp_alloc() 285 if nx_http_response_parse(nb, nbt[0], nr) != 0 { return 9 } 286 let nno: *i64 = sys_mmap(8) as *i64 287 let nnl: *i64 = sys_mmap(8) as *i64 288 if nx_http_response_header(nb, nbt[0], nr, "Replay-Nonce" as *u8, 12, nno, nnl) != 1 { return 10 } 289 var nonce: *u8 = acme_dup_cstr(nb, nno[0], nnl[0]) 290 var nonce_n: i64 = nnl[0] 291 292 // ---- 3. account key (reuse persistent ES256 key) + newAccount ---- 293 let priv: *i64 = u256_alloc() 294 acme_account_key(priv) 295 let px: *i64 = u256_alloc() 296 let py: *i64 = u256_alloc() 297 nx_p256_pubkey_from_priv(priv, px, py) 298 let email: *u8 = "elderwesto@gmail.com" as *u8 299 let jws: *u8 = sys_mmap(ACME_MAGIC_8192) 300 let jwsn: *i64 = sys_mmap(8) as *i64 301 if nx_acme_build_new_account_jws_es256(priv, px, py, nonce, nonce_n, 302 acct_url, acct_url_n, email, 20, 303 jws, ACME_MAGIC_8192, jwsn) != NXACMEES_OK { d1_pe("JWS build FAIL\n" as *u8); return 11 } 304 let ct: *u8 = "application/jose+json" as *u8 305 let ab: *u8 = sys_mmap(ACME_MAGIC_16384) 306 let abt: *i64 = sys_mmap(8) as *i64 307 if nx_acme_req(acct_url, acct_url_n, 1, ct, 21, jws, jwsn[0], store, now, ab, ACME_MAGIC_16384, abt) != NX_ACME_HTTP_OK { d1_pe("newAccount req FAIL\n" as *u8); return 12 } 308 let ar: *i64 = nx_http_resp_alloc() 309 if nx_http_response_parse(ab, abt[0], ar) != 0 { return 13 } 310 let sc: i64 = ar[1] 311 if sc != 200 { if sc != 201 { 312 d1_pe("newAccount FAIL body:\n" as *u8) 313 sys_write(2, ab + ar[6], abt[0] - ar[6]); sys_write(2, "\n" as *u8, 1) 314 return 20 315 } } 316 let lo: *i64 = sys_mmap(8) as *i64 317 let ll: *i64 = sys_mmap(8) as *i64 318 if nx_http_response_header(ab, abt[0], ar, "Location" as *u8, 8, lo, ll) != 1 { return 21 } 319 let kid: *u8 = acme_dup_cstr(ab, lo[0], ll[0]) 320 let kid_n: i64 = ll[0] 321 let n2o: *i64 = sys_mmap(8) as *i64 322 let n2l: *i64 = sys_mmap(8) as *i64 323 if nx_http_response_header(ab, abt[0], ar, "Replay-Nonce" as *u8, 12, n2o, n2l) != 1 { return 22 } 324 var nonce2: *u8 = acme_dup_cstr(ab, n2o[0], n2l[0]) 325 var nonce2_n: i64 = n2l[0] 326 d1_pp("account ready (kid acquired)\n" as *u8) 327 328 // ---- 4. newOrder: nishifamily.com + *.nishifamily.com ---- 329 let dp: *i64 = sys_mmap(32) as *i64 330 let dl: *i64 = sys_mmap(32) as *i64 331 dp[0] = dom as i64; dl[0] = dom_n 332 dp[1] = wild as i64; dl[1] = wild_n 333 let opay: *u8 = sys_mmap(512) 334 let opoff: *i64 = sys_mmap(8) as *i64 335 opoff[0] = 0 336 if nx_acme_payload_new_order(opay, opoff, 512, dp, dl, 2) != NXACMEES_OK { return 30 } 337 let ojws: *u8 = sys_mmap(ACME_MAGIC_8192) 338 let ojn: *i64 = sys_mmap(8) as *i64 339 if acme_signed_post(priv, kid, kid_n, nonce2, nonce2_n, order_url, order_url_n, opay, opoff[0], ojws, ACME_MAGIC_8192, ojn) != 0 { return 31 } 340 let ob: *u8 = sys_mmap(ACME_MAGIC_16384) 341 let obt: *i64 = sys_mmap(8) as *i64 342 if nx_acme_req(order_url, order_url_n, 1, ct, 21, ojws, ojn[0], store, now, ob, ACME_MAGIC_16384, obt) != NX_ACME_HTTP_OK { return 32 } 343 let orr: *i64 = nx_http_resp_alloc() 344 if nx_http_response_parse(ob, obt[0], orr) != 0 { return 33 } 345 if orr[1] != 201 { 346 d1_pe("newOrder body:\n" as *u8); sys_write(2, ob + orr[6], obt[0] - orr[6]); sys_write(2, "\n" as *u8, 1) 347 return 34 348 } 349 d1_pp("newOrder 201 (" as *u8); sys_write(1, dom, dom_n); d1_pp(" + " as *u8); sys_write(1, wild, wild_n); d1_pp(")\n" as *u8) 350 let oolo: *i64 = sys_mmap(8) as *i64 351 let ooll: *i64 = sys_mmap(8) as *i64 352 var the_order_url: *u8 = 0 as *u8 353 var the_order_url_n: i64 = 0 354 if nx_http_response_header(ob, obt[0], orr, "Location" as *u8, 8, oolo, ooll) == 1 { 355 the_order_url = acme_dup_cstr(ob, oolo[0], ooll[0]); the_order_url_n = ooll[0] 356 } 357 let obody: i64 = orr[6] 358 let oblen: i64 = obt[0] - obody 359 let fvo: *i64 = sys_mmap(8) as *i64 360 let fvl: *i64 = sys_mmap(8) as *i64 361 if nx_acme_json_str(ob, obody, oblen, "finalize" as *u8, 8, fvo, fvl) != 1 { return 35 } 362 let finalize_url: *u8 = acme_dup_cstr(ob, fvo[0], fvl[0]) 363 let finalize_url_n: i64 = fvl[0] 364 365 // authorizations[] -- 1 or 2 (apex + wildcard); require at least the first. 366 let authz_urls: *i64 = sys_mmap(64) as *i64 367 let authz_url_ns: *i64 = sys_mmap(64) as *i64 368 let a0o: *i64 = sys_mmap(8) as *i64; let a0l: *i64 = sys_mmap(8) as *i64 369 let a1o: *i64 = sys_mmap(8) as *i64; let a1l: *i64 = sys_mmap(8) as *i64 370 if acme_json_array_nth(ob, obody, oblen, "authorizations" as *u8, 14, 0, a0o, a0l) != 1 { return 36 } 371 authz_urls[0] = (acme_dup_cstr(ob, a0o[0], a0l[0])) as i64; authz_url_ns[0] = a0l[0] 372 var n_authz: i64 = 1 373 if acme_json_array_nth(ob, obody, oblen, "authorizations" as *u8, 14, 1, a1o, a1l) == 1 { 374 authz_urls[1] = (acme_dup_cstr(ob, a1o[0], a1l[0])) as i64; authz_url_ns[1] = a1l[0] 375 n_authz = 2 376 } 377 let n3o: *i64 = sys_mmap(8) as *i64 378 let n3l: *i64 = sys_mmap(8) as *i64 379 if nx_http_response_header(ob, obt[0], orr, "Replay-Nonce" as *u8, 12, n3o, n3l) != 1 { return 38 } 380 var nonce3: *u8 = acme_dup_cstr(ob, n3o[0], n3l[0]) 381 var nn: i64 = n3l[0] 382 383 // ---- 5. DNS-01 PASS 1: per authz, find dns-01, compute TXT, Porkbun CREATE ---- 384 let chal_urls: *i64 = sys_mmap(64) as *i64 385 let chal_url_ns: *i64 = sys_mmap(64) as *i64 386 let skip: *i64 = sys_mmap(64) as *i64 387 // Expected TXT values we actually push to Porkbun -- polled for visibility 388 // at the authoritative NS before triggering (step 6). 389 let txt_vals: *i64 = sys_mmap(128) as *i64 390 let txt_lens: *i64 = sys_mmap(128) as *i64 391 var n_txt: i64 = 0 392 var created_any: i64 = 0 393 var ai: i64 = 0 394 while ai < n_authz { 395 skip[ai] = 0 396 chal_urls[ai] = 0 397 chal_url_ns[ai] = 0 398 let au: *u8 = authz_urls[ai] as *u8 399 let aun: i64 = authz_url_ns[ai] 400 let aj: *u8 = sys_mmap(ACME_MAGIC_8192) 401 let ajn: *i64 = sys_mmap(8) as *i64 402 if acme_signed_post(priv, kid, kid_n, nonce3, nn, au, aun, "" as *u8, 0, aj, ACME_MAGIC_8192, ajn) != 0 { 403 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 404 return 40 405 } 406 let zb: *u8 = sys_mmap(ACME_MAGIC_16384) 407 let zbt: *i64 = sys_mmap(8) as *i64 408 if nx_acme_req(au, aun, 1, ct, 21, aj, ajn[0], store, now, zb, ACME_MAGIC_16384, zbt) != NX_ACME_HTTP_OK { 409 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 410 return 41 411 } 412 let zr: *i64 = nx_http_resp_alloc() 413 if nx_http_response_parse(zb, zbt[0], zr) != 0 { 414 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 415 return 42 416 } 417 nonce3 = acme_nonce_from(zb, zbt[0], zr, &nn) 418 if (nonce3 as i64) == 0 { 419 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 420 return 43 421 } 422 // already valid? (LE reuses recent validations) -> skip this authz. 423 let aso: *i64 = sys_mmap(8) as *i64 424 let asl: *i64 = sys_mmap(8) as *i64 425 var is_valid: i64 = 0 426 if nx_acme_json_str(zb, zr[6], zbt[0] - zr[6], "status" as *u8, 6, aso, asl) == 1 { 427 if asl[0] == 5 { if zb[aso[0]] == 0x76 { is_valid = 1 } } // "valid" 428 } 429 if is_valid == 1 { 430 skip[ai] = 1 431 d1_pp(" authz already valid (reused) -- skipping challenge\n" as *u8) 432 } else { 433 let cto: *i64 = sys_mmap(8) as *i64 434 let ctl: *i64 = sys_mmap(8) as *i64 435 let cuo: *i64 = sys_mmap(8) as *i64 436 let cul: *i64 = sys_mmap(8) as *i64 437 if d1_find_dns01(zb, zr[6], zbt[0] - zr[6], cto, ctl, cuo, cul) != 1 { 438 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 439 return 44 440 } 441 let token: *u8 = acme_dup_cstr(zb, cto[0], ctl[0]) 442 let token_n: i64 = ctl[0] 443 chal_urls[ai] = (acme_dup_cstr(zb, cuo[0], cul[0])) as i64 444 chal_url_ns[ai] = cul[0] 445 // DNS-01 TXT value = base64url(SHA256(token "." b64url(account JWK thumbprint))) 446 let txt: *u8 = sys_mmap(128) 447 let txtn: *i64 = sys_mmap(8) as *i64 448 if nx_acme_dns01_txt_value(token, token_n, px, py, txt, 128, txtn) != NXACMEES_OK { 449 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 450 return 45 451 } 452 d1_pp(" creating _acme-challenge." as *u8); sys_write(1, dom, dom_n); d1_pp(" TXT = " as *u8) 453 sys_write(1, txt, txtn[0]); sys_write(1, "\n" as *u8, 1) 454 let presp: *u8 = sys_mmap(ACME_MAGIC_16384) 455 let prn: *i64 = sys_mmap(8) as *i64 456 let sv: i64 = nx_porkbun_set_txt(pk_ak, pk_akn, pk_sk, pk_skn, dom, dom_n, txt, txtn[0], store, now, presp, ACME_MAGIC_16384, prn) 457 created_any = 1 458 if sv != NX_PORKBUN_OK { 459 d1_pe("porkbun set TXT FAIL -- API response below:\n" as *u8) 460 sys_write(1, presp, prn[0]) 461 sys_write(1, "\n" as *u8, 1) 462 d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) 463 return 46 464 } 465 // remember this value so step 6 can poll for its DNS visibility 466 txt_vals[n_txt] = txt as i64 467 txt_lens[n_txt] = txtn[0] 468 n_txt = n_txt + 1 469 } 470 ai = ai + 1 471 } 472 473 // ---- 6. DNS propagation poll (real, sovereign) ---- 474 // REPLACES the old fixed sys_sleep_ms(360000). Actively poll the 475 // AUTHORITATIVE Porkbun nameservers (via nx_acme_dns01_propagation: NS 476 // discovery -> NS IP -> direct TXT query, dodging public-resolver stale 477 // cache) until EVERY created _acme-challenge TXT value is visible -- the 478 // exact data Let's Encrypt validates -- then trigger IMMEDIATELY. Bounded 479 // by NX_DNS01_POLL_TIMEOUT_MS (~6 min): a propagation stall falls through 480 // to trigger anyway, so this is never slower than the old fixed wait, and 481 // usually MUCH faster (seconds, not minutes) + avoids the marginal-timing 482 // failures of a guessed sleep. A premature trigger would burn an LE 483 // validation attempt against the 5/hour/hostname budget, so visibility is 484 // confirmed FIRST. 485 if created_any == 1 { 486 d1_pp(" polling authoritative NS for _acme-challenge TXT propagation (trigger as soon as visible; <=360s ceiling)...\n" as *u8) 487 let prop: i64 = nx_dns01_wait_txt_propagated(dom, dom_n, txt_vals, txt_lens, n_txt, NX_DNS01_POLL_TIMEOUT_MS, now) 488 if prop == 1 { 489 d1_pp(" all TXT records VISIBLE at the authoritative NS -> triggering now\n" as *u8) 490 } else { 491 d1_pp(" propagation poll hit the 360s ceiling -> triggering anyway (bounded fallback)\n" as *u8) 492 } 493 } 494 495 // ---- 7. DNS-01 PASS 2: trigger each non-skipped challenge ---- 496 var ti: i64 = 0 497 while ti < n_authz { 498 if skip[ti] == 0 { 499 let curl: *u8 = chal_urls[ti] as *u8 500 let curln: i64 = chal_url_ns[ti] 501 let tjws: *u8 = sys_mmap(ACME_MAGIC_8192) 502 let tjn: *i64 = sys_mmap(8) as *i64 503 if acme_signed_post(priv, kid, kid_n, nonce3, nn, curl, curln, "{}" as *u8, 2, tjws, ACME_MAGIC_8192, tjn) != 0 { 504 d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n); return 50 505 } 506 let tb: *u8 = sys_mmap(ACME_MAGIC_16384) 507 let tbt: *i64 = sys_mmap(8) as *i64 508 if nx_acme_req(curl, curln, 1, ct, 21, tjws, tjn[0], store, now, tb, ACME_MAGIC_16384, tbt) != NX_ACME_HTTP_OK { 509 d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n); return 51 510 } 511 let tr: *i64 = nx_http_resp_alloc() 512 nx_http_response_parse(tb, tbt[0], tr) 513 nonce3 = acme_nonce_from(tb, tbt[0], tr, &nn) 514 d1_pp(" dns-01 challenge triggered\n" as *u8) 515 } 516 ti = ti + 1 517 } 518 519 // ---- 8. DNS-01 PASS 3: poll each authz until "valid" ---- 520 var pi: i64 = 0 521 while pi < n_authz { 522 if skip[pi] == 0 { 523 let au2: *u8 = authz_urls[pi] as *u8 524 let aun2: i64 = authz_url_ns[pi] 525 var done: i64 = 0 526 var loops: i64 = 0 527 while done == 0 { 528 if loops >= 40 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n); return 52 } 529 sys_sleep_ms(ACME_MAGIC_3000) 530 let pj: *u8 = sys_mmap(ACME_MAGIC_8192) 531 let pjn: *i64 = sys_mmap(8) as *i64 532 if acme_signed_post(priv, kid, kid_n, nonce3, nn, au2, aun2, "" as *u8, 0, pj, ACME_MAGIC_8192, pjn) != 0 { 533 d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n); return 53 534 } 535 let pb: *u8 = sys_mmap(ACME_MAGIC_16384) 536 let pbt: *i64 = sys_mmap(8) as *i64 537 if nx_acme_req(au2, aun2, 1, ct, 21, pj, pjn[0], store, now, pb, ACME_MAGIC_16384, pbt) != NX_ACME_HTTP_OK { 538 d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n); return 54 539 } 540 let pr: *i64 = nx_http_resp_alloc() 541 nx_http_response_parse(pb, pbt[0], pr) 542 nonce3 = acme_nonce_from(pb, pbt[0], pr, &nn) 543 let so: *i64 = sys_mmap(8) as *i64 544 let sl: *i64 = sys_mmap(8) as *i64 545 if nx_acme_json_str(pb, pr[6], pbt[0] - pr[6], "status" as *u8, 6, so, sl) == 1 { 546 if sl[0] == 5 { if pb[so[0]] == 0x76 { done = 1 } } // "valid" 547 if sl[0] == 7 { if pb[so[0]] == 0x69 { // "invalid" 548 d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n); return 55 549 } } 550 } 551 loops = loops + 1 552 } 553 d1_pp(" authz VALID\n" as *u8) 554 } 555 pi = pi + 1 556 } 557 558 // ---- 9. cleanup TXT records (validation complete; idempotent) ---- 559 if created_any == 1 { d1_cleanup(pk_ak, pk_akn, pk_sk, pk_skn, store, now, dom, dom_n) } 560 d1_pp("all authorizations valid; DNS TXT cleaned up\n" as *u8) 561 562 // ---- 10. cert private key: persistent 32-byte big-endian P-256 scalar ---- 563 // (EXACTLY the format nx_sites_daemon loads as le_ecdsa_key.bin.) 564 let cpriv_be: *u8 = sys_mmap(32) 565 let ckfd: i64 = sys_openat_rd(ckey_path) 566 if ckfd > 0 { 567 let cn2: i64 = sys_read(ckfd, cpriv_be, 32); sys_close(ckfd) 568 if cn2 != 32 { nx_csprng_fill(cpriv_be, 32) } 569 } else { 570 nx_csprng_fill(cpriv_be, 32) 571 let cwf: i64 = sys_openat_wr(ckey_path, 0x180) // 0600 572 if cwf > 0 { sys_write(cwf, cpriv_be, 32); sys_close(cwf) } 573 } 574 let cpriv: *i64 = u256_alloc(); u256_load_be(cpriv, cpriv_be) 575 let cpx: *i64 = u256_alloc() 576 let cpy: *i64 = u256_alloc() 577 nx_p256_pubkey_from_priv(cpriv, cpx, cpy) 578 579 // ---- 11. CSR (CN=<dom>, SAN <dom> + *.<dom>) ---- 580 let sp: *i64 = sys_mmap(32) as *i64 581 let sln: *i64 = sys_mmap(32) as *i64 582 sp[0] = dom as i64; sln[0] = dom_n 583 sp[1] = wild as i64; sln[1] = wild_n 584 let csr: *u8 = sys_mmap(ACME_MAGIC_2048) 585 let csrn: *i64 = sys_mmap(8) as *i64 586 if nx_acme_build_csr_ecdsa_p256(dom, dom_n, sp, sln, 2, cpriv, cpx, cpy, csr, ACME_MAGIC_2048, csrn) != 0 { return 70 } 587 588 // ---- 12. finalize (POST the CSR) ---- 589 let fpay: *u8 = sys_mmap(ACME_MAGIC_4096) 590 let fpo: *i64 = sys_mmap(8) as *i64 591 fpo[0] = 0 592 if nx_acme_payload_finalize(fpay, fpo, ACME_MAGIC_4096, csr, csrn[0]) != NXACMEES_OK { return 71 } 593 let fjws: *u8 = sys_mmap(ACME_MAGIC_8192) 594 let fjn: *i64 = sys_mmap(8) as *i64 595 if acme_signed_post(priv, kid, kid_n, nonce3, nn, finalize_url, finalize_url_n, fpay, fpo[0], fjws, ACME_MAGIC_8192, fjn) != 0 { return 72 } 596 let fb: *u8 = sys_mmap(ACME_MAGIC_16384) 597 let fbt: *i64 = sys_mmap(8) as *i64 598 if nx_acme_req(finalize_url, finalize_url_n, 1, ct, 21, fjws, fjn[0], store, now, fb, ACME_MAGIC_16384, fbt) != NX_ACME_HTTP_OK { return 73 } 599 let fr: *i64 = nx_http_resp_alloc() 600 nx_http_response_parse(fb, fbt[0], fr) 601 nonce3 = acme_nonce_from(fb, fbt[0], fr, &nn) 602 d1_pp("finalize posted\n" as *u8) 603 604 // ---- 13. poll the order until the certificate URL appears ---- 605 var oi: i64 = 0 606 var cert_url: *u8 = 0 as *u8 607 var cert_url_n: i64 = 0 608 while oi < 40 { 609 sys_sleep_ms(ACME_MAGIC_3000) 610 let qj: *u8 = sys_mmap(ACME_MAGIC_8192) 611 let qjn: *i64 = sys_mmap(8) as *i64 612 if acme_signed_post(priv, kid, kid_n, nonce3, nn, the_order_url, the_order_url_n, "" as *u8, 0, qj, ACME_MAGIC_8192, qjn) != 0 { return 74 } 613 let qb: *u8 = sys_mmap(ACME_MAGIC_16384) 614 let qbt: *i64 = sys_mmap(8) as *i64 615 if nx_acme_req(the_order_url, the_order_url_n, 1, ct, 21, qj, qjn[0], store, now, qb, ACME_MAGIC_16384, qbt) != NX_ACME_HTTP_OK { return 75 } 616 let qr: *i64 = nx_http_resp_alloc() 617 nx_http_response_parse(qb, qbt[0], qr) 618 nonce3 = acme_nonce_from(qb, qbt[0], qr, &nn) 619 let cvo: *i64 = sys_mmap(8) as *i64 620 let cvl: *i64 = sys_mmap(8) as *i64 621 if nx_acme_json_str(qb, qr[6], qbt[0] - qr[6], "certificate" as *u8, 11, cvo, cvl) == 1 { 622 cert_url = acme_dup_cstr(qb, cvo[0], cvl[0]); cert_url_n = cvl[0]; oi = 40 623 } 624 oi = oi + 1 625 } 626 if (cert_url as i64) == 0 { d1_pe("order poll: no certificate url\n" as *u8); return 76 } 627 628 // ---- 14. download the cert chain (PEM) ---- 629 let dj: *u8 = sys_mmap(ACME_MAGIC_8192) 630 let djn: *i64 = sys_mmap(8) as *i64 631 if acme_signed_post(priv, kid, kid_n, nonce3, nn, cert_url, cert_url_n, "" as *u8, 0, dj, ACME_MAGIC_8192, djn) != 0 { return 77 } 632 let dbb: *u8 = sys_mmap(ACME_MAGIC_65536) 633 let dbt: *i64 = sys_mmap(8) as *i64 634 if nx_acme_req(cert_url, cert_url_n, 1, ct, 21, dj, djn[0], store, now, dbb, ACME_MAGIC_65536, dbt) != NX_ACME_HTTP_OK { return 78 } 635 let dr: *i64 = nx_http_resp_alloc() 636 nx_http_response_parse(dbb, dbt[0], dr) 637 let pem_off: i64 = dr[6] 638 let pem_n: i64 = dbt[0] - pem_off 639 // save the raw PEM for inspection 640 let pfd: i64 = sys_openat_wr(out_pem, 0x1A4) 641 if pfd > 0 { sys_write(pfd, dbb + pem_off, pem_n); sys_close(pfd) } 642 643 // ---- 15. PEM chain -> concatenated DER fullchain (leaf first) ---- 644 let fullchain: *u8 = sys_mmap(ACME_MAGIC_65536) 645 var fc_off: i64 = 0 646 let der_tmp: *u8 = sys_mmap(ACME_MAGIC_8192) 647 let lo2: *i64 = sys_mmap(8) as *i64 648 let ll2: *i64 = sys_mmap(8) as *i64 649 let pem_end: i64 = pem_off + pem_n 650 var pos: i64 = pem_off 651 var ncerts: i64 = 0 652 var more: i64 = 1 653 while more == 1 { 654 let bpos: i64 = d1_find_begin(dbb, pem_end, pos) 655 if bpos < 0 { more = 0 } else { 656 let der_n: i64 = pem_decode((dbb + bpos) as *u8, pem_end - bpos, der_tmp, ACME_MAGIC_8192, lo2, ll2) 657 if der_n <= 0 { more = 0 } else { 658 var ci: i64 = 0 659 while ci < der_n { fullchain[fc_off + ci] = der_tmp[ci]; ci = ci + 1 } 660 fc_off = fc_off + der_n 661 ncerts = ncerts + 1 662 pos = bpos + 11 663 } 664 } 665 } 666 if ncerts < 1 { d1_pe("PEM->DER produced 0 certs\n" as *u8); return 80 } 667 if fc_off < 100 { d1_pe("fullchain DER suspiciously small\n" as *u8); return 81 } 668 669 // ---- 16. write outputs to /tmp ONLY (human reviews + deploys) ---- 670 let ffd: i64 = sys_openat_wr(out_der, 0x1A4) // 0644 671 if ffd <= 0 { d1_pe("open fullchain output FAIL\n" as *u8); return 82 } 672 sys_write(ffd, fullchain, fc_off); sys_close(ffd) 673 let kfd: i64 = sys_openat_wr(out_key, 0x180) // 0600 674 if kfd <= 0 { d1_pe("open key output FAIL\n" as *u8); return 83 } 675 sys_write(kfd, cpriv_be, 32); sys_close(kfd) 676 677 d1_pp("*** DNS-01 WILDCARD CERT ISSUED ***\n" as *u8) 678 d1_pp(" fullchain DER (leaf first): " as *u8); d1_pp(out_der); d1_pp("\n" as *u8) 679 d1_pp(" ecdsa key (32B BE P-256): " as *u8); d1_pp(out_key); d1_pp("\n" as *u8) 680 d1_pp(" fullchain PEM (inspection): " as *u8); d1_pp(out_pem); d1_pp("\n" as *u8) 681 d1_pp(" -> REVIEW, then deploy to /volume1/.../certs/ (human drives deploy)\n" as *u8) 682 return 0 683}