acumen

Acumen is a Gleam library for interacting with ACME servers (such as Let’s Encrypt) to automate certificate issuance and management.

Architecture

Acumen uses a sans-IO pattern, meaning it produces HTTP request descriptions and consumes response data rather than performing I/O directly. This makes it:

Quick Start

import acumen
import acumen/nonce
import acumen/register_account
import gleam/http/request
import gleam/httpc
import gose/jwk
import kryptos/ec

pub fn main() {
  // 1. Fetch the ACME directory
  let assert Ok(req) = request.to("https://acme-v02.api.letsencrypt.org/directory")
  let assert Ok(resp) = httpc.send(req)
  let assert Ok(directory) = acumen.directory(resp)

  // 2. Get an initial nonce
  let assert Ok(nonce_req) = nonce.build(directory)
  let assert Ok(nonce_resp) = httpc.send(nonce_req)
  let assert Ok(initial_nonce) = nonce.response(nonce_resp)

  // 3. Create context and account key
  let ctx = acumen.Context(directory:, nonce: initial_nonce)
  let key = jwk.generate_ec(ec.P256)
  let unregistered = acumen.UnregisteredKey(key)

  // 4. Register an account
  let reg = register_account.request()
    |> register_account.contacts(["mailto:admin@example.com"])
    |> register_account.agree_to_terms

  let assert Ok(#(resp, ctx)) = acumen.execute(
    ctx,
    build: register_account.build(reg, _, unregistered),
    send: httpc.send,
  )

  let assert Ok(#(account, registered_key)) =
    register_account.response(resp, unregistered)
}

Types

All errors that can occur during ACME operations.

pub type AcmeError {
  InvalidRequest(message: String)
  InvalidResponse(message: String)
  JsonParseError(message: String)
  JwsError(message: String)
  CryptoError(message: String)
  InvalidChallenge(message: String)
  AccountDoesNotExist(detail: String)
  AlreadyReplaced(detail: String)
  AlreadyRevoked(detail: String)
  BadCsr(detail: String)
  BadNonce(detail: String)
  BadPublicKey(detail: String)
  BadRevocationReason(detail: String)
  BadSignatureAlgorithm(detail: String)
  CaaError(detail: String)
  CompoundError(detail: String, subproblems: List(Subproblem))
  ConnectionError(detail: String)
  DnsError(detail: String)
  ExternalAccountRequired(detail: String)
  IncorrectResponse(detail: String)
  InvalidContact(detail: String)
  MalformedError(detail: String)
  OrderNotReady(detail: String)
  RateLimited(detail: String)
  RejectedIdentifier(detail: String)
  ServerInternalError(detail: String)
  TlsError(detail: String)
  Unauthorized(detail: String)
  UnsupportedContact(detail: String)
  UnsupportedIdentifier(detail: String)
  UserActionRequired(
    detail: String,
    instance: option.Option(String),
  )
  UnknownError(
    type_: String,
    detail: String,
    status: option.Option(Int),
  )
}

Constructors

  • InvalidRequest(message: String)

    An input value was invalid when building a request (e.g., malformed URI).

  • InvalidResponse(message: String)

    The server response was malformed or unexpected.

  • JsonParseError(message: String)

    JSON decoding failed.

  • JwsError(message: String)

    JWS signing or encoding failed.

  • CryptoError(message: String)

    A cryptographic operation failed (e.g., key thumbprint).

  • InvalidChallenge(message: String)

    The challenge type does not support the requested operation.

  • AccountDoesNotExist(detail: String)

    The account does not exist on the server.

  • AlreadyReplaced(detail: String)

    The certificate has already been replaced by another order.

  • AlreadyRevoked(detail: String)

    The certificate was already revoked.

  • BadCsr(detail: String)

    The CSR is unacceptable.

  • BadNonce(detail: String)

    The nonce was invalid. Automatically retried by execute.

  • BadPublicKey(detail: String)

    The public key is unacceptable.

  • BadRevocationReason(detail: String)

    The revocation reason is unacceptable.

  • BadSignatureAlgorithm(detail: String)

    The JWS algorithm is not supported by the server.

  • CaaError(detail: String)

    CAA records forbid certificate issuance.

  • CompoundError(detail: String, subproblems: List(Subproblem))

    Multiple errors occurred; check subproblems for details.

  • ConnectionError(detail: String)

    The server could not connect to the client for validation.

  • DnsError(detail: String)

    A DNS lookup failed during validation.

  • ExternalAccountRequired(detail: String)

    External account binding is required by the server.

  • IncorrectResponse(detail: String)

    The challenge validation response was incorrect.

  • InvalidContact(detail: String)

    A contact URL in the account is invalid.

  • MalformedError(detail: String)

    The request was malformed.

  • OrderNotReady(detail: String)

    The order is not ready for finalization.

  • RateLimited(detail: String)

    Too many requests; consider backing off.

  • RejectedIdentifier(detail: String)

    The server will not issue for the requested identifier.

  • ServerInternalError(detail: String)

    An internal server error occurred.

  • TlsError(detail: String)

    A TLS error occurred during validation.

  • Unauthorized(detail: String)

    The client is not authorized for the requested operation.

  • UnsupportedContact(detail: String)

    The contact URL scheme is not supported by the server.

  • UnsupportedIdentifier(detail: String)

    The identifier type is not supported by the server.

  • UserActionRequired(
      detail: String,
      instance: option.Option(String),
    )

    The user must visit a URL to complete an action; check the instance field for the URL.

  • UnknownError(
      type_: String,
      detail: String,
      status: option.Option(Int),
    )

    An unrecognized error type from the server.

Lightweight state container for building ACME requests.

ACME requires a fresh nonce for each request. The execute function automatically updates the context with nonces from response headers.

pub type Context {
  Context(directory: Directory, nonce: String)
}

Constructors

  • Context(directory: Directory, nonce: String)

    Arguments

    directory

    The ACME directory with endpoint URLs.

    nonce

    The current nonce for replay protection.

ACME directory containing endpoint URLs for all ACME operations.

The directory is fetched once from the ACME server and provides the URLs needed to interact with the ACME API.

Example

let assert Ok(req) = request.to("https://acme-v02.api.letsencrypt.org/directory")
let assert Ok(resp) = httpc.send(req)
let assert Ok(directory) = acumen.directory(resp)
pub type Directory {
  Directory(
    new_nonce: url.Url,
    new_account: url.Url,
    new_order: url.Url,
    revoke_cert: url.Url,
    key_change: url.Url,
    new_authz: option.Option(url.Url),
    renewal_info: option.Option(uri.Uri),
    meta: option.Option(DirectoryMeta),
  )
}

Constructors

  • Directory(
      new_nonce: url.Url,
      new_account: url.Url,
      new_order: url.Url,
      revoke_cert: url.Url,
      key_change: url.Url,
      new_authz: option.Option(url.Url),
      renewal_info: option.Option(uri.Uri),
      meta: option.Option(DirectoryMeta),
    )

    Arguments

    new_nonce

    URL for fetching replay-protection nonces.

    new_account

    URL for account registration.

    new_order

    URL for creating certificate orders.

    revoke_cert

    URL for revoking certificates.

    key_change

    URL for changing account keys.

    new_authz

    URL for pre-authorization.

    renewal_info

    URL for fetching renewal information.

    meta

    Optional server metadata (terms of service, CAA identities, etc.).

Optional metadata from the ACME directory.

Provides additional information about the ACME server’s policies and capabilities.

pub type DirectoryMeta {
  DirectoryMeta(
    terms_of_service: option.Option(uri.Uri),
    website: option.Option(uri.Uri),
    caa_identities: List(String),
    external_account_required: Bool,
    profiles: dict.Dict(String, String),
  )
}

Constructors

  • DirectoryMeta(
      terms_of_service: option.Option(uri.Uri),
      website: option.Option(uri.Uri),
      caa_identities: List(String),
      external_account_required: Bool,
      profiles: dict.Dict(String, String),
    )

    Arguments

    terms_of_service

    URL for the terms of service that users must agree to.

    website

    Website for the certificate authority.

    caa_identities

    Domain names for DNS CAA record validation.

    external_account_required

    Whether external account binding is required for registration.

    profiles

    Available certificate issuance profiles.

Errors returned by the execute function.

This type is parameterized by e, the error type of your HTTP transport.

pub type ExecuteError(e) {
  ProtocolError(error: AcmeError, context: Context)
  TransportError(e)
  NonceRetryExhausted
}

Constructors

  • ProtocolError(error: AcmeError, context: Context)

    An ACME protocol error returned by the server.

  • TransportError(e)

    An HTTP transport error (e.g., network failure, timeout).

  • NonceRetryExhausted

    All nonce retry attempts were exhausted.

An identifier representing a domain name or IP address for certificate issuance.

pub type Identifier {
  DnsIdentifier(value: String)
  IpIdentifier(value: String)
}

Constructors

  • DnsIdentifier(value: String)

    A DNS identifier representing a fully qualified domain name (e.g., "example.com" or "*.example.com" for wildcard certificates).

  • IpIdentifier(value: String)

    An IP identifier representing an IPv4 or IPv6 address (e.g., "192.0.2.1" or "2001:db8::1").

An account key that has been registered with the ACME server.

After successful registration, the key is paired with a kid (key ID), which is the account URL.

The RegisteredKey is returned by register_account.response after a successful account registration.

pub type RegisteredKey {
  RegisteredKey(jwk: jwk.Jwk, kid: url.Url)
}

Constructors

Parsed Retry-After header value.

Per RFC 9110, the Retry-After header can be either delta-seconds (an integer) or an HTTP date.

pub type RetryAfter {
  RetryAfterSeconds(Int)
  RetryAfterTimestamp(timestamp.Timestamp)
}

Constructors

  • RetryAfterSeconds(Int)

    Wait this many seconds before retrying.

  • RetryAfterTimestamp(timestamp.Timestamp)

    Retry after this specific point in time.

A subproblem within a compound ACME error.

Compound errors contain multiple subproblems, each potentially associated with a specific identifier that caused the issue.

pub type Subproblem {
  Subproblem(
    type_: String,
    detail: String,
    identifier: option.Option(Identifier),
  )
}

Constructors

An account key that has not yet been registered with the ACME server.

Example

import gose/jwk
import kryptos/ec

let key = jwk.generate_ec(ec.P256)
let unregistered = acumen.UnregisteredKey(key)
pub type UnregisteredKey {
  UnregisteredKey(jwk: jwk.Jwk)
}

Constructors

Values

pub fn directory(
  resp: response.Response(String),
) -> Result(Directory, AcmeError)

Parses an ACME directory response.

The directory is the entry point for all ACME operations. Fetch it with a GET to the server’s directory URL.

Example

// Let's Encrypt production
let assert Ok(req) = request.to("https://acme-v02.api.letsencrypt.org/directory")
let assert Ok(resp) = httpc.send(req)
let assert Ok(directory) = acumen.directory(resp)

// Let's Encrypt staging
let assert Ok(req) = request.to("https://acme-staging-v02.api.letsencrypt.org/directory")
let assert Ok(resp) = httpc.send(req)
let assert Ok(directory) = acumen.directory(resp)
pub fn execute(
  context: Context,
  build build_request: fn(Context) -> Result(
    request.Request(String),
    AcmeError,
  ),
  send send: fn(request.Request(String)) -> Result(
    response.Response(String),
    e,
  ),
) -> Result(
  #(response.Response(String), Context),
  ExecuteError(e),
)

Executes an ACME request with automatic nonce retry handling.

Builds the signed request, sends it, and retries on badNonce errors (up to 3 retries). Updates the context with fresh nonces from each response.

Example

let registration = register_account.request()
  |> register_account.contacts(["mailto:admin@example.com"])
  |> register_account.agree_to_terms

let result = acumen.execute(
  ctx,
  build: register_account.build(registration, _, unregistered_key),
  send: httpc.send,
)

case result {
  Ok(#(resp, new_ctx)) -> {
    // Process successful response
  }
  Error(acumen.ProtocolError(error: acumen.RateLimited(_), context: _)) -> {
    // Back off and retry later
  }
  Error(acumen.TransportError(e)) -> {
    // Handle network error
  }
  Error(acumen.NonceRetryExhausted) -> {
    // All retries failed
  }
}
pub fn external_account_required(directory: Directory) -> Bool

Returns True if the server requires external account binding.

Returns False if the directory has no metadata or the field is not set.

pub fn profiles(
  directory: Directory,
) -> dict.Dict(String, String)

Returns available certificate issuance profiles as a dictionary of profile names to descriptions. Empty if the server advertises none.

Example

let available_profiles = acumen.profiles(directory)
case dict.get(available_profiles, "tlsserver") {
  Ok(description) -> io.println("TLS Server profile: " <> description)
  Error(Nil) -> io.println("TLS Server profile not available")
}
pub fn retry_after(
  resp: response.Response(body),
) -> Result(RetryAfter, Nil)

Extracts the Retry-After header value from a response.

Handles both delta-seconds and HTTP-date formats per RFC 9110.

Example

case acumen.retry_after(resp) {
  Ok(acumen.RetryAfterSeconds(seconds)) -> {
    // Wait for `seconds` before retrying
  }
  Ok(acumen.RetryAfterTimestamp(timestamp)) -> {
    // Retry after `timestamp`
  }
  Error(Nil) -> {
    // No retry-after header, use default backoff
  }
}
pub fn terms_of_service(
  directory: Directory,
) -> Result(uri.Uri, Nil)

Returns the terms of service URL from the directory metadata, if present.

pub const version: String

Library version used in User-Agent header.

Search Document