Skip to content

Instantly share code, notes, and snippets.

@foxoman
Created April 15, 2025 12:02
Show Gist options
  • Save foxoman/a6bb5d3e2b8a6502dead516c4ef9a649 to your computer and use it in GitHub Desktop.
Save foxoman/a6bb5d3e2b8a6502dead516c4ef9a649 to your computer and use it in GitHub Desktop.
Nim ACME Client
# Nim ACME Client for SSL/TLS Certificate Management
## A high-performance RFC 8555 compliant ACME client for automated SSL/TLS certificate management
##
## This package provides a complete API for obtaining, managing, and renewing SSL/TLS certificates
## using the ACME protocol (RFC 8555), compatible with Let's Encrypt and other ACME providers.
##
## Basic usage example:
## ```nim
## import asyncdispatch
## import acme_client
##
## proc main() {.async.} =
## # Create a new ACME client (defaults to Let's Encrypt production)
## let client = newAcmeClient()
##
## # Create account
## let account = await client.createAccount("[email protected]")
##
## # Create certificate order
## let domains = @["example.com", "www.example.com"]
## let order = await client.newOrder(domains)
##
## # Complete domain authorization
## let authUrl = order["authorizations"][0].getStr()
## let auth = await client.getAuthorization(authUrl)
## let challenge = auth["challenges"][0]
## discard await client.respondToChallenge(challenge)
## discard await client.pollForStatus(authUrl, "valid")
##
## # Generate key and CSR, finalize order
## let keyPath = "domain.key"
## let csr = generateCsr(domains, keyPath)
## let finalizedOrder = await client.finalizeCertificateOrder(
## order["finalize"].getStr(), csr)
##
## # Download and save certificate
## let certData = await client.downloadCertificate(finalizedOrder["certificate"].getStr())
## saveCertificate(certData, "example.com.crt")
##
## when isMainModule:
## waitFor main()
## ```
import
std/[
asyncdispatch, base64, httpclient, json, options, os, strformat, strutils, times,
tables, osproc, random, sequtils,
]
randomize()
type
RSAPrivateKey = object
n, e, d, p, q, dmp1, dmq1, iqmp: seq[byte]
AcmeClient* = ref object
directoryUrl*: string
accountKey*: string
accountKeyPair*: Option[RSAPrivateKey]
accountUrl*: string
nonce*: string
httpClient*: AsyncHttpClient
directory*: JsonNode
nonceCache*: seq[string]
cacheDir*: string
requestTimeout*: int
AcmeError* = object of CatchableError
statusCode*: int
detail*: string
AcmeRateLimitError* = object of AcmeError
AcmeAuthorizationError* = object of AcmeError
AcmeServerError* = object of AcmeError
const
USER_AGENT = "Nim-ACME-Client/1.0"
DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
DEFAULT_STAGING_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
REQUEST_TIMEOUT = 30_000 # 30 seconds in milliseconds
MAX_RETRIES = 5
RETRY_BACKOFF_MS = 1000
NONCE_CACHE_SIZE = 10
proc genTempFilename(prefix: string, suffix: string): string =
## Generates a unique temporary file name with the given prefix and suffix.
let tempDir = getTempDir()
let randomPart = rand(1_000_000).intToStr()
result = fmt"{tempDir}/{prefix}{randomPart}{suffix}"
proc newAcmeClient*(
directoryUrl = DEFAULT_DIRECTORY_URL, cacheDir = ".acme-cache"
): AcmeClient =
## Creates a new ACME client with the specified directory URL and cache directory.
##
## Parameters:
## - directoryUrl: The ACME server directory URL. Defaults to Let's Encrypt production.
## Use DEFAULT_STAGING_URL for testing.
## - cacheDir: Directory to store cache files. Created if it doesn't exist.
##
## Returns:
## - A new AcmeClient instance ready to be used.
##
## Example:
## ```nim
## # Production Let's Encrypt
## let client = newAcmeClient()
##
## # Let's Encrypt staging environment (for testing)
## let stagingClient = newAcmeClient(DEFAULT_STAGING_URL)
## ```
if not dirExists(cacheDir):
createDir(cacheDir)
var httpClient = newAsyncHttpClient(userAgent = USER_AGENT)
httpClient.timeout = REQUEST_TIMEOUT
result = AcmeClient(
directoryUrl: directoryUrl,
httpClient: httpClient,
nonceCache: @[],
cacheDir: cacheDir,
requestTimeout: REQUEST_TIMEOUT,
)
proc generateRsaKey*(bits = 2048): string =
## Generates a new RSA private key with the specified bit size.
##
## Parameters:
## - bits: The RSA key size in bits. Default is 2048.
##
## Returns:
## - The generated RSA private key in PEM format.
##
## Example:
## ```nim
## let privateKey = generateRsaKey(4096) # Generate a 4096-bit RSA key
## writeFile("domain.key", privateKey)
## ```
let tmpFile = genTempFilename("acme_key_", ".pem")
let cmd = "openssl genrsa -out " & tmpFile & " " & $bits
let (output, exitCode) = execCmdEx(cmd)
if exitCode != 0:
raise newException(IOError, "Failed to generate RSA key: " & output)
result = readFile(tmpFile)
removeFile(tmpFile)
proc loadRsaKey*(pemData: string): RSAPrivateKey =
## Loads an RSA private key from PEM-encoded string data.
##
## Parameters:
## - pemData: The PEM-encoded RSA private key.
##
## Returns:
## - An RSAPrivateKey object representing the loaded key.
let pemFile = genTempFilename("acme_key_", ".pem")
writeFile(pemFile, pemData)
var n, e, d, p, q, dmp1, dmq1, iqmp: seq[byte]
let modCmd = "openssl rsa -in " & pemFile & " -noout -modulus"
let (modOutput, modExitCode) = execCmdEx(modCmd)
if modExitCode == 0 and modOutput.startsWith("Modulus="):
let modHex = modOutput.splitLines()[0][8 ..^ 1]
n = newSeq[byte](modHex.len div 2)
for i in countup(0, modHex.len - 2, 2):
n[i div 2] = byte(parseHexInt(modHex[i .. i + 1]))
let textCmd = "openssl rsa -in " & pemFile & " -noout -text"
let (textOutput, textExitCode) = execCmdEx(textCmd)
if textExitCode != 0:
removeFile(pemFile)
raise newException(IOError, "Failed to load RSA private key: " & textOutput)
for line in textOutput.splitLines():
if line.contains("publicExponent:"):
let parts = line.split("(")
if parts.len > 1:
let exponentStr = parts[1].split(")")[0]
let expValue = parseHexInt(exponentStr.replace("0x", ""))
e = @[(expValue shr 16).byte, (expValue shr 8).byte, expValue.byte]
break
removeFile(pemFile)
result =
RSAPrivateKey(n: n, e: e, d: d, p: p, q: q, dmp1: dmp1, dmq1: dmq1, iqmp: iqmp)
proc base64UrlEncode(data: string): string =
if data.len == 0:
return ""
result = encode(data).replace('+', '-').replace('/', '_').replace("=", "")
proc base64UrlDecode(data: string): string =
if data.len == 0:
return ""
var padded = data.replace('-', '+').replace('_', '/')
let padding = (4 - (padded.len mod 4)) mod 4
padded.add repeat('=', padding)
result = decode(padded)
proc logInfo(msg: string) =
echo "[" & now().format("yyyy-MM-dd HH:mm:ss") & "] INFO: " & msg
proc logError(msg: string) =
echo "[" & now().format("yyyy-MM-dd HH:mm:ss") & "] ERROR: " & msg
proc loadDirectory*(client: AcmeClient): Future[JsonNode] {.async.} =
## Loads the ACME directory JSON from the server.
##
## This is usually called automatically by other methods when needed.
##
## Parameters:
## - client: The AcmeClient instance.
##
## Returns:
## - The directory JSON containing ACME API endpoints.
##
## Example:
## ```nim
## let directory = await client.loadDirectory()
## echo "newAccount endpoint: ", directory["newAccount"].getStr()
## ```
if client.directory != nil:
return client.directory
let response = await client.httpClient.get(client.directoryUrl)
if response.code != Http200:
raise newException(AcmeError, fmt"Failed to load directory: HTTP {response.code}")
let body = await response.body
client.directory = parseJson(body)
return client.directory
proc createAccount*(client: AcmeClient, email: string): Future[JsonNode] {.async.} =
## Creates a new ACME account with the given contact email.
##
## Parameters:
## - client: The AcmeClient instance.
## - email: The contact email for the account.
##
## Returns:
## - The account details as a JsonNode.
##
## Example:
## ```nim
## let account = await client.createAccount("[email protected]")
## echo "Account URL: ", client.accountUrl
## ```
if client.directory == nil:
discard await client.loadDirectory()
let accountUrl = client.directory["newAccount"].getStr
let payload = %*{"termsOfServiceAgreed": true, "contact": ["mailto:" & email]}
let response = await client.httpClient.post(accountUrl, body = $payload)
if response.code != Http201:
let responseBody = await response.body
raise newException(AcmeError, fmt"Failed to create account: {responseBody}")
client.accountUrl = response.headers["Location"]
let responseBody = await response.body
result = parseJson(responseBody)
proc newOrder*(client: AcmeClient, domains: seq[string]): Future[JsonNode] {.async.} =
## Creates a new certificate order for the specified domains.
##
## Parameters:
## - client: The AcmeClient instance.
## - domains: A sequence of domain names to include in the certificate.
##
## Returns:
## - The order details as a JsonNode.
##
## Example:
## ```nim
## let domains = @["example.com", "www.example.com"]
## let order = await client.newOrder(domains)
## ```
if client.directory == nil:
discard await client.loadDirectory()
let payload = %*{"identifiers": domains.mapIt(%*{"type": "dns", "value": it})}
let newOrderUrl = client.directory["newOrder"].getStr
let response = await client.httpClient.post(newOrderUrl, body = $payload)
if response.code != Http201:
let responseBody = await response.body
raise newException(AcmeError, fmt"Failed to create new order: {responseBody}")
let responseBody = await response.body
result = parseJson(responseBody)
proc getAuthorization*(
client: AcmeClient, authUrl: string
): Future[JsonNode] {.async.} =
## Retrieves the details of an authorization.
##
## Parameters:
## - client: The AcmeClient instance.
## - authUrl: The URL of the authorization to retrieve.
##
## Returns:
## - The authorization details as a JsonNode.
##
## Example:
## ```nim
## let authUrl = order["authorizations"][0].getStr()
## let auth = await client.getAuthorization(authUrl)
## ```
let response = await client.httpClient.get(authUrl)
if response.code != Http200:
let responseBody = await response.body
raise newException(AcmeError, fmt"Failed to get authorization: {responseBody}")
let responseBody = await response.body
result = parseJson(responseBody)
proc respondToChallenge*(
client: AcmeClient, challenge: JsonNode
): Future[JsonNode] {.async.} =
## Responds to a challenge to prove domain ownership.
##
## Parameters:
## - client: The AcmeClient instance.
## - challenge: The challenge JsonNode from the authorization.
##
## Returns:
## - The challenge response as a JsonNode.
##
## Example:
## ```nim
## let challenge = auth["challenges"][0]
## let response = await client.respondToChallenge(challenge)
## ```
let challengeUrl = challenge["url"].getStr
let payload = %*{}
let response = await client.httpClient.post(challengeUrl, body = $payload)
if response.code != Http200:
let responseBody = await response.body
raise newException(AcmeError, fmt"Failed to respond to challenge: {responseBody}")
let responseBody = await response.body
result = parseJson(responseBody)
proc pollForStatus*(
client: AcmeClient, url: string, desiredStatus: string
): Future[JsonNode] {.async.} =
## Polls a URL until the resource has the desired status.
##
## Parameters:
## - client: The AcmeClient instance.
## - url: The URL to poll.
## - desiredStatus: The status to wait for (e.g., "valid").
##
## Returns:
## - The resource as a JsonNode once it reaches the desired status.
##
## Example:
## ```nim
## let validAuth = await client.pollForStatus(authUrl, "valid")
## ```
var status = ""
var retries = 0
var responseJson: JsonNode
while status != desiredStatus and retries < MAX_RETRIES:
let response = await client.httpClient.get(url)
if response.code != Http200:
let responseBody = await response.body
raise newException(AcmeError, fmt"Failed to poll for status: {responseBody}")
let responseBody = await response.body
responseJson = parseJson(responseBody)
status = responseJson["status"].getStr
if status == desiredStatus:
return responseJson
retries += 1
await sleepAsync(RETRY_BACKOFF_MS)
if status != desiredStatus:
raise newException(
AcmeError, fmt"Timed out waiting for status to become {desiredStatus}"
)
result = responseJson
proc generateCsr*(domains: seq[string], keyPath: string): string =
## Generates a Certificate Signing Request (CSR) for the specified domains.
##
## Parameters:
## - domains: A sequence of domain names to include in the CSR.
## - keyPath: Path to the private key file to use for the CSR.
##
## Returns:
## - Base64URL-encoded CSR in DER format.
##
## Example:
## ```nim
## let domains = @["example.com", "www.example.com"]
## let csr = generateCsr(domains, "domain.key")
## ```
# Create a temporary config file for OpenSSL
let configFile = genTempFilename("acme_config_", ".cnf")
let csrFile = genTempFilename("acme_csr_", ".pem")
var configContent =
"""
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = """ &
domains[0] &
"""
[v3_req]
subjectAltName = @alt_names
[alt_names]
"""
for i, domain in domains:
configContent.add("DNS." & $(i + 1) & " = " & domain & "\n")
writeFile(configFile, configContent)
# Generate CSR
let cmd =
"openssl req -new -key " & keyPath & " -out " & csrFile & " -config " & configFile &
" -nodes"
let (output, exitCode) = execCmdEx(cmd)
if exitCode != 0:
removeFile(configFile)
raise newException(IOError, "Failed to generate CSR: " & output)
# Convert CSR to DER format and then to Base64URL
let derFile = genTempFilename("acme_csr_", ".der")
let derCmd = "openssl req -in " & csrFile & " -outform DER -out " & derFile
let (derOutput, derExitCode) = execCmdEx(derCmd)
if derExitCode != 0:
removeFile(configFile)
removeFile(csrFile)
raise newException(IOError, "Failed to convert CSR to DER: " & derOutput)
let csrData = readFile(derFile)
# Clean up temporary files
removeFile(configFile)
removeFile(csrFile)
removeFile(derFile)
# Base64URL encode the CSR
result = base64UrlEncode(csrData)
proc finalizeCertificateOrder*(
client: AcmeClient, finalizeUrl: string, csr: string
): Future[JsonNode] {.async.} =
## Finalizes a certificate order by submitting the CSR.
##
## Parameters:
## - client: The AcmeClient instance.
## - finalizeUrl: The finalize URL from the order response.
## - csr: Base64URL-encoded CSR.
##
## Returns:
## - The finalized order as a JsonNode.
##
## Example:
## ```nim
## let finalizedOrder = await client.finalizeCertificateOrder(order["finalize"].getStr(), csr)
## ```
let payload = %*{"csr": csr}
let response = await client.httpClient.post(finalizeUrl, body = $payload)
if response.code notin {Http200, Http201, Http202}:
let responseBody = await response.body
raise newException(AcmeError, fmt"Failed to finalize order: {responseBody}")
let responseBody = await response.body
result = parseJson(responseBody)
proc downloadCertificate*(
client: AcmeClient, certUrl: string
): Future[string] {.async.} =
## Downloads the issued certificate.
##
## Parameters:
## - client: The AcmeClient instance.
## - certUrl: The certificate URL from the finalized order.
##
## Returns:
## - The PEM-encoded certificate chain.
##
## Example:
## ```nim
## let certUrl = finalizedOrder["certificate"].getStr()
## let certData = await client.downloadCertificate(certUrl)
## ```
let response = await client.httpClient.get(certUrl)
if response.code != Http200:
let responseBody = await response.body
raise newException(
AcmeError,
fmt"Failed to download certificate. Status code: {response.code}. Body: {responseBody}",
)
return await response.body
proc saveCertificate*(certData: string, outputPath: string) =
## Saves the certificate data to a file.
##
## Parameters:
## - certData: The PEM-encoded certificate chain.
## - outputPath: The path where the certificate will be saved.
##
## Example:
## ```nim
## saveCertificate(certData, "example.com.crt")
## ```
writeFile(outputPath, certData)
proc main() {.async.} =
let client = newAcmeClient()
let email = "[email protected]"
let domains = @["example.com", "www.example.com"]
try:
let account = await client.createAccount(email)
logInfo("Account created successfully: " & $account)
let order = await client.newOrder(domains)
logInfo("Order created successfully: " & $order)
let authUrl = order["authorizations"][0].getStr()
let authorization = await client.getAuthorization(authUrl)
logInfo("Authorization fetched: " & $authorization)
let challenge = authorization["challenges"][0]
let response = await client.respondToChallenge(challenge)
logInfo("Challenge responded to: " & $response)
let validatedAuth = await client.pollForStatus(authUrl, "valid")
logInfo("Authorization validated: " & $validatedAuth)
let keyPath = "domain.key"
let csr = generateCsr(domains, keyPath)
let finalizeUrl = order["finalize"].getStr()
let finalizedOrder = await client.finalizeCertificateOrder(finalizeUrl, csr)
logInfo("Order finalized: " & $finalizedOrder)
let certUrl = finalizedOrder["certificate"].getStr()
let certData = await client.downloadCertificate(certUrl)
saveCertificate(certData, "example.com.crt")
logInfo("Certificate downloaded and saved.")
except AcmeError as e:
logError("ACME error occurred: " & e.msg)
except:
logError("Unexpected error: " & getCurrentExceptionMsg())
when isMainModule:
waitFor main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment