acumen

Package Version Hex Docs

Acumen is a Gleam library for interacting with Automatic Certificate Management Environment (ACME) servers like Let’s Encrypt. It handles account registration, domain challenges, and TLS certificate issuance.

Features

Installation

gleam add acumen

You will also need an HTTP client library to send the requests that acumen builds. For Erlang targets, gleam_httpc is a good choice. For JavaScript targets, gleam_fetch works well.

Examples

Acumen follows a build -> send -> parse pattern for every operation. The acumen.execute function orchestrates this loop and handles automatic badNonce retry.

Fetching the directory and initial nonce

Before any ACME operation, you need to fetch the server directory and an initial replay nonce.

import acumen
import acumen/nonce
import gleam/http/request
import gleam/httpc

// Fetch the ACME directory
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)

// 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)

// Create context for subsequent requests
let ctx = acumen.Context(directory:, nonce: initial_nonce)

Registering an account

Generate a key and register an account with the ACME server.

import acumen/register_account
import gose/jwk
import kryptos/ec

// Generate an account key
let key = jwk.generate_ec(ec.P256)
let unregistered = acumen.UnregisteredKey(key)

// Build and submit the registration
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)

From here on, use registered_key for all subsequent operations.

Creating an order

Create an order for the domains you want a certificate for.

import acumen/create_order

// Identifiers can be DNS names or IP addresses
let assert Ok(order_req) = create_order.request(
  identifiers: [acumen.DnsIdentifier("example.com"), acumen.DnsIdentifier("www.example.com")],
)
// For IP certificates: acumen.IpIdentifier("192.0.2.1")

let assert Ok(#(resp, ctx)) = acumen.execute(
  ctx,
  build: create_order.build(order_req, _, registered_key),
  send: httpc.send,
)

let assert Ok(ord) = create_order.response(resp)

Completing challenges

For each authorization in the order, fetch it and complete the appropriate challenge.

import acumen/challenge
import acumen/fetch_authorization
import acumen/validate_challenge
import gleam/list

let assert [auth_url, ..] = ord.authorizations

// Fetch the authorization
let assert Ok(#(resp, ctx)) = acumen.execute(
  ctx,
  build: fetch_authorization.build(auth_url, _, registered_key),
  send: httpc.send,
)

let assert Ok(auth) = fetch_authorization.response(resp, auth_url)

// Find the HTTP-01 challenge
let assert Ok(http_challenge) =
  challenge.find_by_type(auth.challenges, of: challenge.Http01)

// Compute the key authorization value
let assert Ok(key_auth) = challenge.key_authorization(http_challenge, registered_key)
let assert Ok(token) = challenge.token(http_challenge)
// Deploy: GET /.well-known/acme-challenge/{token} -> key_auth

// Tell the server to validate
let assert Ok(#(resp, ctx)) = acumen.execute(
  ctx,
  build: validate_challenge.build(challenge.url(http_challenge), _, registered_key),
  send: httpc.send,
)

let assert Ok(validated_challenge) = validate_challenge.response(resp)

Finalizing the order

Once all challenges are validated, generate a CSR and finalize the order.

import acumen/finalize_order
import acumen/order
import kryptos/ec

// Generate a certificate key pair and CSR from the order
// Use order.to_rsa_csr(ord, rsa_key) for RSA keys
let #(cert_key, _pub) = ec.generate_key_pair(ec.P256)
let assert Ok(csr) = order.to_ec_csr(ord, cert_key)

// Finalize the order
let assert Ok(#(resp, ctx)) = acumen.execute(
  ctx,
  build: finalize_order.build(ord.finalize_url, _, registered_key, csr:),
  send: httpc.send,
)

let assert Ok(finalized_order) = finalize_order.response(resp, ord.url)

Fetching the certificate

After the order is finalized, poll until the order reaches the Valid status, then download the certificate.

import acumen/fetch_certificate
import acumen/fetch_order
import acumen/order
import gleam/erlang/process

// Poll until the order is valid
let assert Ok(#(resp, ctx)) = acumen.execute(
  ctx,
  build: fetch_order.build(ord.url, _, registered_key),
  send: httpc.send,
)

let assert Ok(completed_order) = fetch_order.response(resp, ord.url)

// The certificate URL is inside the Valid status variant
let assert order.Valid(cert_url) = completed_order.status

// Download the certificate chain (PEM format)
let assert Ok(#(resp, ctx)) = acumen.execute(
  ctx,
  build: fetch_certificate.build(cert_url, _, registered_key),
  send: httpc.send,
)

let assert Ok(pem_chain) = fetch_certificate.response(resp)
// pem_chain contains the full certificate chain in PEM format

If the order is still processing, use acumen.retry_after(resp) to determine how long to wait before polling again.

Error handling

All operations return Result types. When using acumen.execute, errors are wrapped in ExecuteError:

Other challenge types

Acumen supports multiple challenge types beyond HTTP-01:

Search Document