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}