nx_acme.nx source
↩ module page · 360 lines · 14864 B
1// nx_acme.nx -- ACME (RFC 8555) state machine + request builders.
2//
3// The cert-issuance state machine for the audit-dashboard phase-3
4// chain. Composes shipped primitives:
5// nx_jose.nx -- JWS flattened-JSON sign (EdDSA)
6// nx_jwk.nx -- JWK canonical emit for OKP
7// nx_ed25519_signature.nx -- account-key signing
8// nx_https_client.nx -- HTTPS request to the CA
9// nx_x509.nx + nx_pem.nx -- CSR + cert handling (in cert-
10// finalize step, queued for v2)
11//
12// Per cardinal feedback-no-third-party-trust-native-or-nothing:
13// the substrate's own ACME client. No certbot, no acme.sh, no
14// Caddy auto-TLS. This is the bits-up replacement.
15//
16// This file is the STATE MACHINE + REQUEST BUILDER layer. Live HTTPS
17// I/O against a CA is the next slice (uses nx_https_client.nx).
18//
19// State machine (RFC 8555 ยง7):
20//
21// IDLE
22// -> DIRECTORY_FETCHING -> DIRECTORY_OK
23// -> NONCE_FETCHING -> NONCE_OK
24// -> ACCOUNT_REGISTERING -> ACCOUNT_REGISTERED
25// -> ORDER_CREATING -> ORDER_CREATED
26// -> AUTHZ_FETCHING -> AUTHZ_PENDING
27// -> CHALLENGE_RESPONDING -> CHALLENGE_VALIDATING
28// -> AUTHZ_VALID
29// -> ORDER_FINALIZING -> ORDER_VALID
30// -> CERT_DOWNLOADING -> COMPLETE
31//
32// Failure terminal: ERROR_PERMANENT / ERROR_TRANSIENT
33//
34// Per cardinal feedback-bounded-loop-discipline-jpl-rule-2: each
35// poll loop (challenge-validating, order-finalizing, cert-download)
36// has an explicit budget counter.
37//
38// nx_capability_claims:
39// needs: [sealed_enum, jose_sign, https_client, x509]
40// provides: [acme_state_machine, acme_new_account_request_builder,
41// acme_order_state]
42// safety: [no_unchecked_deref, no_floating_point,
43// bounded_poll_budget]
44// verdict: [sealed_enum_18_state + sealed_enum_3_challenge]
45// license: ORIGINAL
46// kind: racing_crew_specialist
47// layer: L4 (composite over L3 jose + x509 + https client)
48
49import "nx_syscalls_x86_64.nx"
50import "nx_jose.nx"
51import "nx_jwk.nx"
52const NXACME_MAGIC_1024: i64 = 1024
53
54// ---- Sealed enum: ACME state -------------------------------------
55
56const NXACME_IDLE: i64 = 0
57const NXACME_DIRECTORY_FETCHING: i64 = 1
58const NXACME_DIRECTORY_OK: i64 = 2
59const NXACME_NONCE_FETCHING: i64 = 3
60const NXACME_NONCE_OK: i64 = 4
61const NXACME_ACCOUNT_REGISTERING: i64 = 5
62const NXACME_ACCOUNT_REGISTERED: i64 = 6
63const NXACME_ORDER_CREATING: i64 = 7
64const NXACME_ORDER_CREATED: i64 = 8
65const NXACME_AUTHZ_FETCHING: i64 = 9
66const NXACME_AUTHZ_PENDING: i64 = 10
67const NXACME_CHALLENGE_RESPONDING: i64 = 11
68const NXACME_CHALLENGE_VALIDATING: i64 = 12
69const NXACME_AUTHZ_VALID: i64 = 13
70const NXACME_ORDER_FINALIZING: i64 = 14
71const NXACME_ORDER_VALID: i64 = 15
72const NXACME_CERT_DOWNLOADING: i64 = 16
73const NXACME_COMPLETE: i64 = 17
74const NXACME_ERROR_PERMANENT: i64 = 18
75const NXACME_ERROR_TRANSIENT: i64 = 19
76const NXACME_STATE_N: i64 = 20
77
78func nx_acme_state_is_valid(s: i64) -> i64 {
79 if s < 0 { return 0 }
80 if s >= NXACME_STATE_N { return 0 }
81 return 1
82}
83
84func nx_acme_state_name(s: i64) -> *u8 {
85 if s == NXACME_IDLE { return "IDLE" as *u8 }
86 if s == NXACME_DIRECTORY_FETCHING { return "DIRECTORY_FETCHING" as *u8 }
87 if s == NXACME_DIRECTORY_OK { return "DIRECTORY_OK" as *u8 }
88 if s == NXACME_NONCE_FETCHING { return "NONCE_FETCHING" as *u8 }
89 if s == NXACME_NONCE_OK { return "NONCE_OK" as *u8 }
90 if s == NXACME_ACCOUNT_REGISTERING { return "ACCOUNT_REGISTERING" as *u8 }
91 if s == NXACME_ACCOUNT_REGISTERED { return "ACCOUNT_REGISTERED" as *u8 }
92 if s == NXACME_ORDER_CREATING { return "ORDER_CREATING" as *u8 }
93 if s == NXACME_ORDER_CREATED { return "ORDER_CREATED" as *u8 }
94 if s == NXACME_AUTHZ_FETCHING { return "AUTHZ_FETCHING" as *u8 }
95 if s == NXACME_AUTHZ_PENDING { return "AUTHZ_PENDING" as *u8 }
96 if s == NXACME_CHALLENGE_RESPONDING { return "CHALLENGE_RESPONDING" as *u8 }
97 if s == NXACME_CHALLENGE_VALIDATING { return "CHALLENGE_VALIDATING" as *u8 }
98 if s == NXACME_AUTHZ_VALID { return "AUTHZ_VALID" as *u8 }
99 if s == NXACME_ORDER_FINALIZING { return "ORDER_FINALIZING" as *u8 }
100 if s == NXACME_ORDER_VALID { return "ORDER_VALID" as *u8 }
101 if s == NXACME_CERT_DOWNLOADING { return "CERT_DOWNLOADING" as *u8 }
102 if s == NXACME_COMPLETE { return "COMPLETE" as *u8 }
103 if s == NXACME_ERROR_PERMANENT { return "ERROR_PERMANENT" as *u8 }
104 if s == NXACME_ERROR_TRANSIENT { return "ERROR_TRANSIENT" as *u8 }
105 return "INVALID" as *u8
106}
107
108// Terminal-state predicate (no further transitions).
109func nx_acme_state_is_terminal(s: i64) -> i64 {
110 if s == NXACME_COMPLETE { return 1 }
111 if s == NXACME_ERROR_PERMANENT { return 1 }
112 return 0
113}
114
115// ---- Sealed enum: challenge type ---------------------------------
116
117const NXACME_CHALLENGE_HTTP_01: i64 = 0
118const NXACME_CHALLENGE_DNS_01: i64 = 1
119const NXACME_CHALLENGE_TLS_ALPN_01: i64 = 2
120const NXACME_CHALLENGE_N: i64 = 3
121
122func nx_acme_challenge_is_valid(c: i64) -> i64 {
123 if c < 0 { return 0 }
124 if c >= NXACME_CHALLENGE_N { return 0 }
125 return 1
126}
127
128func nx_acme_challenge_name(c: i64) -> *u8 {
129 if c == NXACME_CHALLENGE_HTTP_01 { return "http-01" as *u8 }
130 if c == NXACME_CHALLENGE_DNS_01 { return "dns-01" as *u8 }
131 if c == NXACME_CHALLENGE_TLS_ALPN_01 { return "tls-alpn-01" as *u8 }
132 return "INVALID" as *u8
133}
134
135// ---- Sealed enum: verdict ----------------------------------------
136
137const NXACME_OK: i64 = 0
138const NXACME_OOM_BUFFER: i64 = 1
139const NXACME_BAD_KEY: i64 = 2
140const NXACME_BAD_STATE: i64 = 3
141const NXACME_BAD_ARG: i64 = 4
142const NXACME_VERDICT_N: i64 = 5
143
144func nx_acme_verdict_is_valid(v: i64) -> i64 {
145 if v < 0 { return 0 }
146 if v >= NXACME_VERDICT_N { return 0 }
147 return 1
148}
149
150// ---- AcmeOrder struct --------------------------------------------
151//
152// Caller-allocated structure tracking the order through its lifecycle.
153// Per cardinal user-owns-every-bit: caller supplies the struct AND
154// the buffers it references.
155
156struct AcmeOrder {
157 state: i64, // sealed enum NXACME_*
158 poll_budget: i64, // remaining ticks for current poll loop
159 n_attempts: i64, // total transitions attempted (diag)
160 last_error: i64, // sealed-enum verdict from last op
161 n_authzs: i64, // count of authorizations in order
162 n_authz_valid: i64, // count moved to VALID so far
163}
164
165const ACME_ORDER_BYTES: i64 = 48 // 6 i64 fields
166
167func nx_acme_order_init(order: *AcmeOrder, poll_budget: i64) -> i64 {
168 if order == (0 as *AcmeOrder) { return NXACME_BAD_ARG }
169 if poll_budget <= 0 { return NXACME_BAD_ARG }
170 order.state = NXACME_IDLE
171 order.poll_budget = poll_budget
172 order.n_attempts = 0
173 order.last_error = NXACME_OK
174 order.n_authzs = 0
175 order.n_authz_valid = 0
176 return NXACME_OK
177}
178
179// Explicit transition. Caller invokes after a HTTP response is
180// parsed. Refuses invalid transitions (sealed-enum gate).
181func nx_acme_order_transition(order: *AcmeOrder, next_state: i64) -> i64 {
182 if order == (0 as *AcmeOrder) { return NXACME_BAD_ARG }
183 if nx_acme_state_is_valid(next_state) != 1 { return NXACME_BAD_STATE }
184 // Terminal-state transitions can move to ERROR_PERMANENT or stay.
185 if nx_acme_state_is_terminal(order.state) == 1 {
186 if order.state == next_state { return NXACME_OK }
187 if next_state == NXACME_ERROR_PERMANENT {
188 order.state = next_state
189 return NXACME_OK
190 }
191 return NXACME_BAD_STATE
192 }
193 order.state = next_state
194 order.n_attempts = order.n_attempts + 1
195 return NXACME_OK
196}
197
198// Decrement poll budget; returns 1 if remaining, 0 if exhausted.
199func nx_acme_order_tick(order: *AcmeOrder) -> i64 {
200 if order == (0 as *AcmeOrder) { return 0 }
201 if order.poll_budget <= 0 { return 0 }
202 order.poll_budget = order.poll_budget - 1
203 return 1
204}
205
206// ---- Protected-header builders -----------------------------------
207//
208// Each ACME request has a protected JWS header. Two flavors:
209//
210// 1. "Jwk" form (new-account, key-rollover): includes the full JWK.
211// Required fields: alg, jwk, nonce, url.
212//
213// 2. "Kid" form (everything else after registration): includes
214// Key ID (account URL). Required fields: alg, kid, nonce, url.
215//
216// We emit the JSON in lexicographic-canonical order per JOSE
217// convention so the byte-output is reproducible.
218
219// Emit `{"alg":"EdDSA","jwk":<jwk>,"nonce":"<nonce>","url":"<url>"}`.
220// jwk MUST be the canonical OKP JWK bytes (caller pre-emitted via
221// nx_jwk_emit_okp_ed25519); we splice it in verbatim.
222func nx_acme_protected_jwk_form(
223 out: *u8, off: *i64, cap: i64,
224 jwk_bytes: *u8, jwk_n: i64,
225 nonce: *u8, nonce_n: i64,
226 url: *u8, url_n: i64) -> i64 {
227 if out == (0 as *u8) { return NXACME_BAD_ARG }
228 if off == (0 as *i64) { return NXACME_BAD_ARG }
229 if jwk_bytes == (0 as *u8) { return NXACME_BAD_ARG }
230 if nonce == (0 as *u8) { return NXACME_BAD_ARG }
231 if url == (0 as *u8) { return NXACME_BAD_ARG }
232 if cap <= 0 { return NXACME_BAD_ARG }
233
234 let r1: i64 = nxjose_put_cstr(out, off, cap, "{\"alg\":\"EdDSA\",\"jwk\":" as *u8)
235 if r1 != NXJOSE_OK { return NXACME_OOM_BUFFER }
236 let r2: i64 = nxjose_put_bytes(out, off, cap, jwk_bytes, jwk_n)
237 if r2 != NXJOSE_OK { return NXACME_OOM_BUFFER }
238 let r3: i64 = nxjose_put_cstr(out, off, cap, ",\"nonce\":\"" as *u8)
239 if r3 != NXJOSE_OK { return NXACME_OOM_BUFFER }
240 let r4: i64 = nxjose_put_bytes(out, off, cap, nonce, nonce_n)
241 if r4 != NXJOSE_OK { return NXACME_OOM_BUFFER }
242 let r5: i64 = nxjose_put_cstr(out, off, cap, "\",\"url\":\"" as *u8)
243 if r5 != NXJOSE_OK { return NXACME_OOM_BUFFER }
244 let r6: i64 = nxjose_put_bytes(out, off, cap, url, url_n)
245 if r6 != NXJOSE_OK { return NXACME_OOM_BUFFER }
246 return nxjose_put_cstr(out, off, cap, "\"}" as *u8)
247}
248
249// Emit `{"alg":"EdDSA","kid":"<kid>","nonce":"<nonce>","url":"<url>"}`.
250func nx_acme_protected_kid_form(
251 out: *u8, off: *i64, cap: i64,
252 kid: *u8, kid_n: i64,
253 nonce: *u8, nonce_n: i64,
254 url: *u8, url_n: i64) -> i64 {
255 if out == (0 as *u8) { return NXACME_BAD_ARG }
256 if off == (0 as *i64) { return NXACME_BAD_ARG }
257 if kid == (0 as *u8) { return NXACME_BAD_ARG }
258 if nonce == (0 as *u8) { return NXACME_BAD_ARG }
259 if url == (0 as *u8) { return NXACME_BAD_ARG }
260 if cap <= 0 { return NXACME_BAD_ARG }
261
262 let r1: i64 = nxjose_put_cstr(out, off, cap, "{\"alg\":\"EdDSA\",\"kid\":\"" as *u8)
263 if r1 != NXJOSE_OK { return NXACME_OOM_BUFFER }
264 let r2: i64 = nxjose_put_bytes(out, off, cap, kid, kid_n)
265 if r2 != NXJOSE_OK { return NXACME_OOM_BUFFER }
266 let r3: i64 = nxjose_put_cstr(out, off, cap, "\",\"nonce\":\"" as *u8)
267 if r3 != NXJOSE_OK { return NXACME_OOM_BUFFER }
268 let r4: i64 = nxjose_put_bytes(out, off, cap, nonce, nonce_n)
269 if r4 != NXJOSE_OK { return NXACME_OOM_BUFFER }
270 let r5: i64 = nxjose_put_cstr(out, off, cap, "\",\"url\":\"" as *u8)
271 if r5 != NXJOSE_OK { return NXACME_OOM_BUFFER }
272 let r6: i64 = nxjose_put_bytes(out, off, cap, url, url_n)
273 if r6 != NXJOSE_OK { return NXACME_OOM_BUFFER }
274 return nxjose_put_cstr(out, off, cap, "\"}" as *u8)
275}
276
277// ---- new-account payload builder ---------------------------------
278//
279// Emits the canonical new-account payload:
280// {"contact":["mailto:<email>"],"termsOfServiceAgreed":true}
281
282func nx_acme_payload_new_account(
283 out: *u8, off: *i64, cap: i64,
284 email: *u8, email_n: i64) -> i64 {
285 if out == (0 as *u8) { return NXACME_BAD_ARG }
286 if off == (0 as *i64) { return NXACME_BAD_ARG }
287 if email == (0 as *u8) { return NXACME_BAD_ARG }
288 if cap <= 0 { return NXACME_BAD_ARG }
289
290 let r1: i64 = nxjose_put_cstr(out, off, cap,
291 "{\"contact\":[\"mailto:" as *u8)
292 if r1 != NXJOSE_OK { return NXACME_OOM_BUFFER }
293 let r2: i64 = nxjose_put_bytes(out, off, cap, email, email_n)
294 if r2 != NXJOSE_OK { return NXACME_OOM_BUFFER }
295 return nxjose_put_cstr(out, off, cap,
296 "\"],\"termsOfServiceAgreed\":true}" as *u8)
297}
298
299// ---- new-account JWS builder (top-level convenience) ------------
300//
301// Composes: jwk-emit -> protected-jwk-form-build -> payload-build ->
302// jose-sign-flattened.
303//
304// Caller supplies:
305// priv_32 Ed25519 private seed
306// pubkey_32 Ed25519 public key
307// nonce Replay-Nonce from CA's HEAD /new-nonce response
308// url CA's new-account URL (from /directory)
309// email contact email
310//
311// Result: the bytes ready to POST to <url> with Content-Type:
312// application/jose+json.
313
314func nx_acme_build_new_account_jws(
315 priv_32: *u8, pubkey_32: *u8,
316 nonce: *u8, nonce_n: i64,
317 url: *u8, url_n: i64,
318 email: *u8, email_n: i64,
319 out: *u8, cap: i64, out_n: *i64) -> i64 {
320 if priv_32 == (0 as *u8) { return NXACME_BAD_KEY }
321 if pubkey_32 == (0 as *u8) { return NXACME_BAD_KEY }
322 if nonce == (0 as *u8) { return NXACME_BAD_ARG }
323 if url == (0 as *u8) { return NXACME_BAD_ARG }
324 if email == (0 as *u8) { return NXACME_BAD_ARG }
325 if out == (0 as *u8) { return NXACME_BAD_ARG }
326 if out_n == (0 as *i64) { return NXACME_BAD_ARG }
327 if cap <= 0 { return NXACME_BAD_ARG }
328
329 // Step 1: emit canonical JWK for the public key.
330 let jwk_buf: *u8 = sys_mmap(256)
331 let jwk_off: *i64 = sys_mmap(8) as *i64
332 jwk_off[0] = 0
333 let jrc: i64 = nx_jwk_emit_okp_ed25519(jwk_buf, jwk_off, 256, pubkey_32)
334 if jrc != NXJWK_OK { return NXACME_OOM_BUFFER }
335
336 // Step 2: build protected header (jwk-form).
337 let prot_buf: *u8 = sys_mmap(NXACME_MAGIC_1024)
338 let prot_off: *i64 = sys_mmap(8) as *i64
339 prot_off[0] = 0
340 let prc: i64 = nx_acme_protected_jwk_form(prot_buf, prot_off, NXACME_MAGIC_1024,
341 jwk_buf, jwk_off[0],
342 nonce, nonce_n,
343 url, url_n)
344 if prc != NXJOSE_OK { return prc }
345
346 // Step 3: build payload (new-account).
347 let pay_buf: *u8 = sys_mmap(512)
348 let pay_off: *i64 = sys_mmap(8) as *i64
349 pay_off[0] = 0
350 let yrc: i64 = nx_acme_payload_new_account(pay_buf, pay_off, 512,
351 email, email_n)
352 if yrc != NXJOSE_OK { return yrc }
353
354 // Step 4: sign + emit flattened-JSON.
355 return nx_jose_sign_eddsa_flattened(
356 prot_buf, prot_off[0],
357 pay_buf, pay_off[0],
358 priv_32,
359 out, cap, out_n)
360}