Created
April 15, 2025 12:02
-
-
Save foxoman/a6bb5d3e2b8a6502dead516c4ef9a649 to your computer and use it in GitHub Desktop.
Nim ACME Client
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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