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:
- HTTP client agnostic: Use any HTTP library (gleam_httpc, gleam_fetch, etc.)
- Target agnostic: Works on both Erlang VM and JavaScript runtimes
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
subproblemsfor 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
instancefield 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
-
An ACME protocol error returned by the server.
-
TransportError(e)An HTTP transport error (e.g., network failure, timeout).
-
NonceRetryExhaustedAll 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
-
Subproblem( type_: String, detail: String, identifier: option.Option(Identifier), )
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
}
}