code wiki / (root) / nx_acme.nx

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}