moonacme

    Transport-neutral ACME certificate automation for MoonBit

    acme
    tls
    certificate
    rfc8555
    letsencrypt
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    7 hours ago
    Downloads
    1

    #MoonACME

    MoonACME is a portable implementation of the Automatic Certificate Management Environment protocol (RFC 8555) for MoonBit. It gives native, JavaScript, and WebAssembly applications the protocol pieces needed to obtain and renew TLS certificates without invoking Certbot or binding the application to one HTTP client, DNS provider, clock, or private-key store.

    The 0.1 API covers the complete request-planning path: directory discovery, accounts, orders, authorizations, HTTP-01 and DNS-01 challenges, replay nonces, flattened JWS, badNonce retries, PKCS#10 CSR creation, finalization, certificate download, revocation, and deterministic renewal scheduling.

    #Why a protocol core?

    MoonBit web servers can already load certificate files, and cryptography packages can already produce hashes and signatures. The missing layer is the ACME state machine that connects those parts safely. MoonACME keeps that layer small and reusable while leaving network and secret-key policy to the host.

    flowchart LR App[MoonBit application] --> Session[AcmeSession] Session --> JWS[JWS and nonce engine] Session --> Flow[order workflow] Flow --> Challenge[HTTP-01 / DNS-01 values] Flow --> CSR[PKCS#10 builder] App --> Transport[host HTTP adapter] App --> Signer[HSM / keystore / signer] Transport --> CA[ACME server] Signer --> JWS Signer --> CSR

    #Quick start

    Clone the repository and run the strict suite:

    git clone https://github.com/apoloe4/moonacme.git cd moonacme moon update moon test --deny-warn --target wasm

    Generate challenge material from the bundled helper:

    moon run cmd/main -- dns01-name '*.example.com' moon run cmd/main -- dns01-value TOKEN ACCOUNT_JWK_THUMBPRINT moon run cmd/main -- http01-path TOKEN moon run cmd/main -- http01-body TOKEN ACCOUNT_JWK_THUMBPRINT

    The same operations are available as library calls:

    ///|
    let record = @moonacme.dns01_record_name("*.example.com")

    ///|
    let value = @moonacme.dns01_txt_value(token, account_thumbprint)

    ///|
    let resource = @moonacme.Http01Resource::new(token, account_thumbprint)

    An AcmeSession consumes each replay nonce once and prepares a request whose signing input can be sent to any signer:

    let session = @moonacme.AcmeSession::new(
    directory~,
    algorithm="ES256",
    public_jwk=canonical_public_jwk,
    )
    session.offer_nonce(replay_nonce) |> ignore
    let draft = session.prepare_new_account(["mailto:ops@example.com"], true)
    let signature = account_signer(draft.draft.signing_input)
    let request = draft.finish(signature[:])

    After decoding an order, call order.next_action() to obtain one of FetchAuthorizations, Finalize, Poll, DownloadCertificate, or Stop. The host executes that effect, feeds the next response back into the library, and can persist the action between runs.

    #Design guarantees

    • Strict decoders report the field and stage that failed.
    • Unknown status and challenge strings are preserved for forward compatibility.
    • Nonces are consumed exactly once and badNonce retries are bounded.
    • JWS and JSON output is deterministic and base64url padding is omitted.
    • Private keys never enter protocol models or debug output.
    • Time is injected into renewal planning, so tests and schedulers agree.
    • The same suite runs on wasm, wasm-gc, js, and native in CI.

    #Project status

    MoonACME 0.1 is suitable for building and testing ACME integrations. It has a transport-neutral API and does not yet ship a ready-made HTTP client, DNS provider plugin, or Pebble interoperability job. See Roadmap, protocol guide, security model, and testing notes.

    Licensed under Apache-2.0.

    AcmeError

    pub(all) suberror AcmeError {
    Malformed(stage~ : String, detail~ : String)
    Protocol(problem~ : AcmeProblem)
    MissingNonce
    RetryExhausted(attempts~ : Int)
    InvalidState(expected~ : String, actual~ : String)
    Unsupported(feature~ : String)
    } derive(Eq,
    Debug
    )

    AcmeError::message

    fn AcmeError::message(self : AcmeError) -> String

    Stable human-readable text for logs and command-line adapters.

    AccountBinding

    pub(all) enum AccountBinding {
    PublicJwk(String)
    KeyId(String)
    } derive(Eq,
    Debug
    )

    How an ACME request identifies its account key. Account creation uses the full public JWK; later requests use the account URL (kid).

    AcmeProblem

    pub(all) struct AcmeProblem {
    kind : String
    title : String?
    detail : String
    status : Int?
    instance : String?
    subproblems : Array[AcmeSubproblem]
    } derive(Eq,
    Debug
    )

    AcmeProblem::decode

    fn AcmeProblem::decode(input : String) -> AcmeProblem raise AcmeError

    AcmeSession

    pub struct AcmeSession {
    directory : Directory
    algorithm : String
    public_jwk : String
    kid : String?
    nonces : NoncePool
    }

    Protocol session state. The directory, account identity and replay nonces live together so a request cannot accidentally use a nonce twice or send a KID request before account creation.

    AcmeSession::account_url

    fn AcmeSession::account_url(self : AcmeSession) -> String?

    AcmeSession::bind_account

    fn AcmeSession::bind_account(self : AcmeSession, kid : String) -> Unit raise AcmeError

    AcmeSession::new

    fn AcmeSession::new(directory~ : Directory, algorithm~ : String, public_jwk~ : String) -> AcmeSession raise AcmeError

    AcmeSession::observe_headers

    fn AcmeSession::observe_headers(self : AcmeSession, headers : Array[Header]) -> Bool

    AcmeSession::offer_nonce

    fn AcmeSession::offer_nonce(self : AcmeSession, nonce : String) -> Bool

    AcmeSession::prepare_challenge_ack

    fn AcmeSession::prepare_challenge_ack(self : AcmeSession, challenge_url : String) -> PreparedRequest raise AcmeError

    AcmeSession::prepare_download

    fn AcmeSession::prepare_download(self : AcmeSession, certificate_url : String) -> PreparedRequest raise AcmeError

    AcmeSession::prepare_finalize

    fn AcmeSession::prepare_finalize(self : AcmeSession, finalize_url : String, csr_der : BytesView) -> PreparedRequest raise AcmeError

    AcmeSession::prepare_new_account

    fn AcmeSession::prepare_new_account(self : AcmeSession, contacts : Array[String], terms_agreed : Bool) -> PreparedRequest raise AcmeError

    AcmeSession::prepare_new_order

    fn AcmeSession::prepare_new_order(self : AcmeSession, identifiers : Array[Identifier]) -> PreparedRequest raise AcmeError

    AcmeSession::prepare_post_as_get

    fn AcmeSession::prepare_post_as_get(self : AcmeSession, url : String, accept? : String) -> PreparedRequest raise AcmeError

    AcmeSession::prepare_revoke

    fn AcmeSession::prepare_revoke(self : AcmeSession, certificate_der : BytesView) -> PreparedRequest raise AcmeError

    AcmeSubproblem

    pub(all) struct AcmeSubproblem {
    kind : String
    detail : String
    identifier : Identifier?
    } derive(Eq,
    Debug
    )

    RFC 8555 uses problem documents for protocol failures. Subproblems preserve per-identifier diagnostics without flattening them into one message.

    Authorization

    pub(all) struct Authorization {
    url : String
    identifier : Identifier
    status : ResourceStatus
    expires : String?
    wildcard : Bool
    challenges : Array[Challenge]
    } derive(Eq,
    Debug
    )

    Authorization::decode

    fn Authorization::decode(input : String, url~ : String) -> Authorization raise AcmeError

    Authorization::find_challenge

    fn Authorization::find_challenge(self : Authorization, kind : ChallengeKind) -> Challenge?

    Authorization::next_action

    fn Authorization::next_action(self : Authorization) -> AuthorizationAction raise AcmeError

    Plan challenge provisioning or polling for an authorization. Pending authorizations expose only challenges the server has not already rejected.

    AuthorizationAction

    pub(all) enum AuthorizationAction {
    Provision(Array[Challenge])
    PollAuthorization(String)
    AuthorizationComplete
    AuthorizationStopped(AcmeProblem?)
    } derive(Eq,
    Debug
    )

    Challenge

    pub(all) struct Challenge {
    kind : ChallengeKind
    url : String
    status : ResourceStatus
    token : String
    validated : String?
    problem : AcmeProblem?
    } derive(Eq,
    Debug
    )

    Challenge::decode

    fn Challenge::decode(input : String) -> Challenge raise AcmeError

    ChallengeKind

    pub(all) enum ChallengeKind {
    Http01
    Dns01
    TlsAlpn01
    Other(String)
    } derive(Eq,
    Debug
    )

    ChallengeKind::parse

    fn ChallengeKind::parse(text : String) -> ChallengeKind

    ChallengeKind::text

    fn ChallengeKind::text(self : ChallengeKind) -> String

    CsrDraft

    pub(all) struct CsrDraft {
    request_info_der : Bytes
    } derive(Eq,
    Debug
    )

    A PKCS#10 certification request prepared for an external signer. Keeping signing outside the protocol package lets applications use an HSM, a platform keystore, or any MoonBit signature implementation.

    CsrDraft::finish_ecdsa_sha256

    fn CsrDraft::finish_ecdsa_sha256(self : CsrDraft, signature_der : BytesView) -> Bytes raise AcmeError

    Assemble a complete PKCS#10 request from an ASN.1 DER ECDSA P-256/SHA-256 signature returned by the caller's signer.

    CsrDraft::new

    fn CsrDraft::new(subject_common_name : String, dns_names : Array[String], subject_public_key_info_der : BytesView) -> CsrDraft raise AcmeError

    Build the DER CertificationRequestInfo for a DNS certificate. The supplied public key is a complete DER SubjectPublicKeyInfo value.

    CsrDraft::signing_input

    fn CsrDraft::signing_input(self : CsrDraft) -> BytesView

    Return the exact DER bytes that the certificate key must sign.

    Directory

    pub(all) struct Directory {
    new_nonce : String
    new_account : String
    new_order : String
    revoke_cert : String
    key_change : String
    renewal_info : String?
    terms_of_service : String?
    website : String?
    external_account_required : Bool
    } derive(Eq,
    Debug
    )

    Directory::decode

    fn Directory::decode(input : String) -> Directory raise AcmeError

    Decode an ACME directory document and its commonly used metadata.
    pub(all) struct Header {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    Http01Resource

    pub(all) struct Http01Resource {
    path : String
    body : String
    content_type : String
    } derive(Eq,
    Debug
    )

    A framework-neutral HTTP-01 resource that can be installed in a router, static-file adapter, or test server.

    Http01Resource::new

    fn Http01Resource::new(token : String, account_thumbprint : String) -> Http01Resource raise AcmeError

    HttpRequest

    pub(all) struct HttpRequest {
    verb : String
    url : String
    headers : Array[Header]
    body : String
    } derive(Eq,
    Debug
    )

    Transport-neutral HTTP request. Native, JavaScript and test transports only need to send this value and return status, headers and bytes.

    Identifier

    pub(all) struct Identifier {
    kind : String
    value : String
    } derive(Eq,
    Debug
    )

    A domain name or another identifier requested from an ACME server.

    JwsDraft

    pub(all) struct JwsDraft {
    protected_b64 : String
    payload_b64 : String
    signing_input : String
    } derive(Eq,
    Debug
    )

    A flattened-JWS request before the cryptographic signature is attached. signing_input is the exact ASCII byte sequence passed to an ES256/RS256 signer. Keeping signing outside the protocol core prevents private keys from entering diagnostic values or portable state.

    JwsDraft::finish

    fn JwsDraft::finish(self : JwsDraft, signature : BytesView) -> String raise AcmeError

    Finish a flattened JWS object with the raw signature bytes returned by the configured algorithm. ES256 adapters must supply the RFC 7515 R || S representation, not an ASN.1 DER ECDSA signature.

    JwsDraft::new

    fn JwsDraft::new(algorithm~ : String, nonce~ : String, url~ : String, payload~ : String, binding~ : AccountBinding) -> JwsDraft raise AcmeError

    JwsDraft::post_as_get

    fn JwsDraft::post_as_get(algorithm~ : String, nonce~ : String, url~ : String, kid~ : String) -> JwsDraft raise AcmeError

    ACME POST-as-GET is a signed request with an empty payload, distinct from a JSON null payload.

    NoncePool

    pub struct NoncePool {
    values : Array[String]
    }

    A small in-memory pool of replay nonces. Nonces are consumed exactly once; duplicates from repeated header processing are ignored.

    NoncePool::clear

    fn NoncePool::clear(self : NoncePool) -> Unit

    NoncePool::length

    fn NoncePool::length(self : NoncePool) -> Int

    NoncePool::new

    fn NoncePool::new() -> NoncePool

    NoncePool::offer

    fn NoncePool::offer(self : NoncePool, nonce : String) -> Bool

    NoncePool::take

    fn NoncePool::take(self : NoncePool) -> String?

    Order

    pub(all) struct Order {
    url : String
    status : ResourceStatus
    identifiers : Array[Identifier]
    authorization_urls : Array[String]
    finalize_url : String
    certificate_url : String?
    expires : String?
    problem : AcmeProblem?
    } derive(Eq,
    Debug
    )

    Order::decode

    fn Order::decode(input : String, url~ : String) -> Order raise AcmeError

    Decode an order body. The resource URL comes from the response Location header and is supplied separately.

    Order::next_action

    fn Order::next_action(self : Order) -> OrderAction raise AcmeError

    Plan the next operation from the server-owned order status.

    OrderAction

    pub(all) enum OrderAction {
    FetchAuthorizations(Array[String])
    Finalize(String)
    Poll(String)
    DownloadCertificate(String)
    Stop(AcmeProblem?)
    } derive(Eq,
    Debug
    )

    The next protocol operation implied by an order resource. Applications can persist this value and resume without duplicating ACME state logic.

    PreparedRequest

    pub(all) struct PreparedRequest {
    verb : String
    url : String
    draft : JwsDraft
    accept : String
    } derive(Eq,
    Debug
    )

    A signed request before its signature is attached.

    PreparedRequest::finish

    fn PreparedRequest::finish(self : PreparedRequest, signature : BytesView) -> HttpRequest raise AcmeError

    RenewalDecision

    pub(all) enum RenewalDecision {
    NotYetValid
    RenewNow(String)
    ScheduleAt(Int64)
    InvalidCertificateWindow
    InvalidRenewalWindow
    } derive(Eq,
    Debug
    )

    RenewalPolicy

    pub(all) struct RenewalPolicy {
    renew_before_seconds : Int64
    minimum_lifetime_seconds : Int64
    } derive(Eq,
    Debug
    )

    RenewalPolicy::default

    fn RenewalPolicy::default() -> RenewalPolicy

    RenewalWindow

    pub(all) struct RenewalWindow {
    start : Int64
    end : Int64
    } derive(Eq,
    Debug
    )

    Optional renewal window returned by an ACME Renewal Information endpoint.

    ResourceStatus

    pub(all) enum ResourceStatus {
    Pending
    Ready
    Processing
    Valid
    Invalid
    Deactivated
    Revoked
    Expired
    Unknown(String)
    } derive(Eq,
    Debug
    )

    Status values shared by ACME account, order, authorization and challenge resources. Unknown values are retained for forward compatibility.

    ResourceStatus::parse

    fn ResourceStatus::parse(text : String) -> ResourceStatus

    ResourceStatus::text

    fn ResourceStatus::text(self : ResourceStatus) -> String

    RetryDecision

    pub(all) enum RetryDecision {
    RetryNow(String)
    FetchFreshNonce
    Stop(AcmeError)
    } derive(Eq,
    Debug
    )

    RetryPolicy

    pub(all) struct RetryPolicy {
    max_bad_nonce_retries : Int
    base_delay_ms : Int
    max_delay_ms : Int
    } derive(Eq,
    Debug
    )

    RetryPolicy::default

    fn RetryPolicy::default() -> RetryPolicy

    canonical_p256_jwk

    fn canonical_p256_jwk(x : BytesView, y : BytesView) -> String raise AcmeError

    Canonical public JWK for an uncompressed P-256 key. Coordinates are the 32-byte unsigned big-endian x and y values, not a DER SubjectPublicKeyInfo.

    decide_bad_nonce_retry

    fn decide_bad_nonce_retry(problem : AcmeProblem, response_nonce : String?, attempt : Int, policy : RetryPolicy) -> RetryDecision

    Decide what to do after a failed signed request. The attempt number is zero-based and counts earlier badNonce retries, not the initial request.

    dns01_record_name

    fn dns01_record_name(identifier : String) -> String raise AcmeError

    Normalize a DNS identifier into the owner name used by DNS-01. A leading wildcard label is removed as required by RFC 8555.

    dns01_txt_value

    fn dns01_txt_value(token : String, account_thumbprint : String) -> String raise AcmeError

    DNS TXT value for _acme-challenge.<domain>.

    encode_new_account

    fn encode_new_account(contacts : Array[String], terms_agreed : Bool) -> String

    Encode a new-account payload. An empty contacts array is valid.

    encode_new_order

    fn encode_new_order(identifiers : Array[Identifier]) -> String

    Encode the RFC 8555 new-order payload in deterministic key order.

    http01_path

    fn http01_path(token : String) -> String raise AcmeError

    URL path provisioned by an HTTP-01 responder.

    is_bad_nonce

    fn is_bad_nonce(problem : AcmeProblem) -> Bool

    jwk_thumbprint

    fn jwk_thumbprint(canonical_public_jwk : String) -> String

    RFC 7638 thumbprint of a caller-supplied canonical public JWK. Callers that use P-256 should prefer p256_jwk_thumbprint below.

    key_authorization

    fn key_authorization(token : String, account_thumbprint : String) -> String raise AcmeError

    RFC 8555 key authorization: token + "." + account-key JWK thumbprint.

    p256_jwk_thumbprint

    fn p256_jwk_thumbprint(x : BytesView, y : BytesView) -> String raise AcmeError

    plan_renewal

    fn plan_renewal(now : Int64, not_before : Int64, not_after : Int64, policy : RenewalPolicy, suggested? : RenewalWindow) -> RenewalDecision

    Plan renewal from caller-provided Unix seconds. The function reads no clock, so applications can test expiry boundaries and inject their own time source.

    replay_nonce

    fn replay_nonce(headers : Array[Header]) -> String?

    Header names are case-insensitive. The final Replay-Nonce wins if a broken intermediary duplicates it, matching the way most HTTP clients expose repeated scalar headers.

    retry_delay_ms

    fn retry_delay_ms(attempt : Int, policy : RetryPolicy) -> Int

    Bounded exponential backoff for polling order and authorization resources.

    validate_challenge_token

    fn validate_challenge_token(token : String) -> Bool

    Check the lexical requirements for an ACME challenge token. The protocol requires at least 128 bits of entropy; 22 base64url characters are the shortest representation that can carry that many bits.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io