From 907021588e688a2e05b76d6812433649f7626c5f Mon Sep 17 00:00:00 2001 From: Buck Brady <22723438+voidrot@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:03:47 -0600 Subject: [PATCH 01/10] add retry for bad responses --- .gitignore | 1 + README.md | 49 +++++ compose.yml | 18 ++ docs/README.md | 32 +++ docs/examples.md | 29 +++ old/acmeClient.ts | 381 +++++++++++++++++++++++++++++++++ old/certUtils.ts | 26 +++ old/index.ts | 5 + src/client.ts | 152 +++++++++---- test/config/pebble-config.json | 0 test/globalSetup.ts | 3 + test/retry.test.ts | 37 ++++ 12 files changed, 690 insertions(+), 43 deletions(-) create mode 100644 README.md create mode 100644 docs/README.md create mode 100644 docs/examples.md create mode 100644 old/acmeClient.ts create mode 100644 old/certUtils.ts create mode 100644 old/index.ts create mode 100644 test/config/pebble-config.json create mode 100644 test/retry.test.ts diff --git a/.gitignore b/.gitignore index 3b3ee33..9a5679c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ *.log *.tsbuildinfo .env +.vscode diff --git a/README.md b/README.md new file mode 100644 index 0000000..d397218 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# node-acme + +ACME client for Node.js. Easily interact with ACME-compatible Certificate Authorities (such as Let's Encrypt) to automate certificate issuance and management. + +## Features +- Written in TypeScript +- Fetch ACME directory metadata +- Register new accounts +- Issue and revoke certificates +- Utility functions for certificate handling + +## Installation + +```sh +pnpm add node-acme +# or +npm install node-acme +``` + +## Usage + +```ts +import { AcmeClient } from 'node-acme' + +const acme = new AcmeClient('https://acme-staging-v02.api.letsencrypt.org/directory') +await acme.init() +// Register account, create orders, etc. +``` + +See [docs/examples.md](docs/examples.md) for more usage examples. + +## Scripts +- `pnpm build` — Build the project +- `pnpm test` — Run tests with Vitest +- `pnpm lint` — Lint and fix code + +## Development +- Source code: [`src/`](src/) +- Tests: [`test/`](test/) +- Examples: [`docs/examples.md`](docs/examples.md) + +## License +MIT + +--- + +> Author: Buck Brady () +> +> [GitHub](https://github.com/voidrot/node-acme) diff --git a/compose.yml b/compose.yml index 5fdfdae..d5195be 100644 --- a/compose.yml +++ b/compose.yml @@ -7,9 +7,27 @@ services: - 15000:15000 # HTTPS Management API environment: - PEBBLE_VA_NOSLEEP=1 + volumes: + - ./test/config/pebble-config.json:/config/pebble-config.json:ro networks: acmenet: ipv4_address: 10.30.50.2 + + pebble-retry: + image: ghcr.io/letsencrypt/pebble:latest + command: -config test/config/pebble-config.json -strict -dnsserver 10.30.50.3:8053 + ports: + - 16000:14000 # HTTPS ACME API + - 17000:15000 # HTTPS Management API + environment: + - PEBBLE_VA_NOSLEEP=1 + - PEBBLE_WFE_NONCEREJECT=80 + volumes: + - ./test/config/pebble-config.json:/config/pebble-config.json:ro + networks: + acmenet: + ipv4_address: 10.30.50.4 + challtestsrv: image: ghcr.io/letsencrypt/pebble-challtestsrv:latest command: -defaultIPv6 "" -defaultIPv4 10.30.50.3 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f41f25e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,32 @@ +# ACME Client Documentation + +This project provides a TypeScript ACME client for interacting with ACME servers (such as Let's Encrypt) to automate certificate issuance. + +## Features +- ACME directory discovery +- Account registration (with JWS/JWK) +- (Planned) Order creation, challenge handling, certificate download + +## Usage Example + +``` +ts +import { AcmeClient } from '../src/acmeClient' + +async function main() { + const acme = new AcmeClient('https://acme-v02.api.letsencrypt.org/directory') + const accountUrl = await acme.createAccount('your@email.com') + console.log('Account URL:', accountUrl) +} + +main().catch(console.error) +``` + +## API + +### `AcmeClient` +- `constructor(directoryUrl: string)` +- `getDirectory(): Promise` +- `createAccount(email: string): Promise` + +See the source for more details. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..a7fd3ec --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,29 @@ +# ACME Client Examples + +## Register an Account + +````ts +import { AcmeClient } from '../src/acmeClient' + +async function main() { + const acme = new AcmeClient('https://acme-staging-v02.api.letsencrypt.org/directory') + const accountUrl = await acme.createAccount('your@email.com') + console.log('Account URL:', accountUrl) +} + +main().catch(console.error) +```` + +## Fetch Directory + +````ts +import { AcmeClient } from '../src/acmeClient' + +async function main() { + const acme = new AcmeClient('https://acme-staging-v02.api.letsencrypt.org/directory') + const dir = await acme.getDirectory() + console.log('Directory:', dir) +} + +main().catch(console.error) +```` diff --git a/old/acmeClient.ts b/old/acmeClient.ts new file mode 100644 index 0000000..4ef8482 --- /dev/null +++ b/old/acmeClient.ts @@ -0,0 +1,381 @@ +// ACME Client for Node.js (TypeScript) +// Basic structure for interfacing with ACME servers (e.g., Let's Encrypt) +// This is a starting point and can be extended for full ACME flows. + +import type { KeyLike } from 'jose' +import { generateKeyPair, exportJWK, FlattenedSign } from 'jose' +import { generateCsr } from './certUtils' + +export interface AcmeDirectory { + newNonce: string + newAccount: string + newOrder: string + revokeCert: string + keyChange: string +} + +export interface AcmeOrder { + status: string + expires?: string + identifiers: { type: string, value: string }[] + authorizations: string[] + finalize: string + certificate?: string +} + +export interface AcmeAuthorization { + status: string + identifier: { type: string, value: string } + challenges: AcmeChallenge[] +} + +export interface AcmeChallenge { + type: string + status: string + url: string + token: string +} + +export interface DnsProvider { + setRecord(domain: string, recordName: string, value: string): Promise + removeRecord(domain: string, recordName: string): Promise +} + +export class AcmeClient { + directoryUrl: string + directory?: AcmeDirectory + private accountUrl?: string + private privateKey?: KeyLike + private dnsProvider?: DnsProvider + + constructor(directoryUrl: string) { + this.directoryUrl = directoryUrl + } + + async getDirectory(): Promise { + const res = await fetch(this.directoryUrl) + if (!res.ok) throw new Error('Failed to fetch ACME directory') + const data = await res.json() as Record + if (!data['newNonce'] || !data['newAccount'] || !data['newOrder'] || !data['revokeCert'] || !data['keyChange']) { + throw new Error('Incomplete ACME directory response') + } + this.directory = { + newNonce: data['newNonce'], + newAccount: data['newAccount'], + newOrder: data['newOrder'], + revokeCert: data['revokeCert'], + keyChange: data['keyChange'] + } + return this.directory + } + + /** + * Create a new ACME account (register a key pair with the server) + * @param email Contact email for the account + * @returns The account URL (kid) and privateKey (for durable storage) + */ + async createAccount(email: string): Promise<{ accountUrl: string, privateKey: KeyLike }> { + if (!this.directory) await this.getDirectory() + // 1. Fetch a fresh nonce + const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) + const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') + if (!nonce) throw new Error('Failed to get ACME nonce') + + // 2. Generate a key pair (ES256) + const { publicKey, privateKey } = await generateKeyPair('ES256') + const jwk = await exportJWK(publicKey) + delete (jwk as Record).key_ops + delete (jwk as Record).ext + + // 3. Build JWS-protected payload for account creation (ACME expects JWS JSON serialization) + const payload = { + termsOfServiceAgreed: true, + contact: [ `mailto:${email}` ] + } + const protectedHeader = { + alg: 'ES256', + nonce, + url: this.directory!.newAccount, + jwk + } + // Use FlattenedSign from jose to create the JWS JSON serialization + const encoder = new TextEncoder() + const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) + .setProtectedHeader(protectedHeader) + .sign(privateKey) + // jws is an object: { protected, payload, signature } + const jwsBody = JSON.stringify(jws) + + // 4. Send account creation request + const res = await fetch(this.directory!.newAccount, { + method: 'POST', + headers: { 'Content-Type': 'application/jose+json' }, + body: jwsBody + }) + if (!res.ok) { + let errorMsg = `Failed to create ACME account: ${res.status} ${res.statusText}` + try { + const errBody = await res.text() + errorMsg += `\nResponse body: ${errBody}` + } + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) + } + // The account URL (kid) is in the Location header + const accountUrl = res.headers.get('Location') + if (!accountUrl) { + let errorMsg = 'No account URL returned by ACME server.' + try { + const respBody = await res.text() + errorMsg += `\nResponse body: ${respBody}` + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) + } + // Store for this instance + this.accountUrl = accountUrl + this.privateKey = privateKey + // Return for durable storage + return { accountUrl, privateKey } + } + + /** + * Restore account state for authenticated requests + */ + restoreAccount(accountUrl: string, privateKey: KeyLike) { + this.accountUrl = accountUrl + this.privateKey = privateKey + } + + /** + * Create a new order for a certificate + * @param domains Array of domain names to include in the certificate + * @returns The ACME order object + */ + async orderCertificate(domains: string[]): Promise { + if (!this.directory) await this.getDirectory() + if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set. Call createAccount or restoreAccount first.') + // 1. Fetch a fresh nonce + const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) + const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') + if (!nonce) throw new Error('Failed to get ACME nonce') + + // 2. Prepare payload + const identifiers = domains.map((d) => ({ type: 'dns', value: d })) + const payload = { identifiers } + // 3. Build protected header (use kid/account URL for authenticated requests) + const protectedHeader = { + alg: 'ES256', + kid: this.accountUrl, + nonce, + url: this.directory!.newOrder + } + // 4. Sign with FlattenedSign + const encoder = new TextEncoder() + const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) + .setProtectedHeader(protectedHeader) + .sign(this.privateKey) + const jwsBody = JSON.stringify(jws) + + // 5. Send order request + const res = await fetch(this.directory!.newOrder, { + method: 'POST', + headers: { 'Content-Type': 'application/jose+json' }, + body: jwsBody + }) + if (!res.ok) { + let errorMsg = `Failed to create ACME order: ${res.status} ${res.statusText}` + try { + const errBody = await res.text() + errorMsg += `\nResponse body: ${errBody}` + } + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) + } + const order: AcmeOrder = await res.json() + return order + } + + setDnsProvider(provider: DnsProvider) { + this.dnsProvider = provider + } + + /** + * Complete DNS-01 challenges for all authorizations in an order + * @param order The ACME order object + * @returns Array of completed authorization URLs + */ + async completeDns01Challenges(order: AcmeOrder): Promise { + if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set. Call createAccount or restoreAccount first.') + if (!this.dnsProvider) throw new Error('DNS provider not set. Call setDnsProvider first.') + // 1. Fetch all authorizations + const completedAuthz: string[] = [] + for (const authzUrl of order.authorizations) { + const authzRes = await fetch(authzUrl) + if (!authzRes.ok) throw new Error(`Failed to fetch authorization: ${authzUrl}`) + const authz: AcmeAuthorization = await authzRes.json() + if (authz.status === 'valid') { + completedAuthz.push(authzUrl) + continue + } + // 2. Find the dns-01 challenge + const challenge = authz.challenges.find((c) => c.type === 'dns-01') + if (!challenge) throw new Error(`No dns-01 challenge for ${authz.identifier.value}`) + // 3. Compute key authorization (stub, real implementation needs JWK thumbprint) + const keyAuthorization = challenge.token + '.KEY_THUMBPRINT_STUB' + // 4. Set DNS record via provider + const recordName = `_acme-challenge.${authz.identifier.value}` + await this.dnsProvider.setRecord(authz.identifier.value, recordName, keyAuthorization) + // 5. Notify ACME server to validate + await this.respondToChallenge(challenge.url, keyAuthorization) + // 6. Wait for challenge to be valid (polling, stubbed as immediate) + completedAuthz.push(authzUrl) + } + return completedAuthz + } + + /** + * Respond to a challenge (POST empty payload to challenge URL) + */ + private async respondToChallenge(challengeUrl: string, keyAuthorization: string) { + if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set.') + if (!this.directory) await this.getDirectory() + // 1. Fetch a fresh nonce + const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) + const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') + if (!nonce) throw new Error('Failed to get ACME nonce') + // 2. Build protected header + const protectedHeader = { + alg: 'ES256', + kid: this.accountUrl, + nonce, + url: challengeUrl + } + // 3. Sign empty payload + const encoder = new TextEncoder() + const jws = await new FlattenedSign(encoder.encode('')) + .setProtectedHeader(protectedHeader) + .sign(this.privateKey) + const jwsBody = JSON.stringify(jws) + // 4. POST to challenge URL + const res = await fetch(challengeUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/jose+json' }, + body: jwsBody + }) + if (!res.ok) { + let errorMsg = `Failed to respond to challenge: ${res.status} ${res.statusText}` + try { + const errBody = await res.text() + errorMsg += `\nResponse body: ${errBody}` + } + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) + } + } + + /** + * Finalize the order and download the certificate + * @param order The ACME order object + * @param csrPem The CSR in PEM format (string) + * @returns The issued certificate (PEM string) + */ + async finalizeOrder(order: AcmeOrder, csrPem: string): Promise { + if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set. Call createAccount or restoreAccount first.') + if (!this.directory) await this.getDirectory() + // 1. Fetch a fresh nonce + const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) + const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') + if (!nonce) throw new Error('Failed to get ACME nonce') + // 2. Prepare payload (base64url-encoded DER CSR, no PEM headers) + const csrDer = Buffer.from(csrPem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64') + const csrB64 = csrDer.toString('base64url') + const payload = { csr: csrB64 } + // 3. Build protected header + const protectedHeader = { + alg: 'ES256', + kid: this.accountUrl, + nonce, + url: order.finalize + } + // 4. Sign with FlattenedSign + const encoder = new TextEncoder() + const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) + .setProtectedHeader(protectedHeader) + .sign(this.privateKey) + const jwsBody = JSON.stringify(jws) + // 5. POST to finalize URL + const res = await fetch(order.finalize, { + method: 'POST', + headers: { 'Content-Type': 'application/jose+json' }, + body: jwsBody + }) + if (!res.ok) { + let errorMsg = `Failed to finalize ACME order: ${res.status} ${res.statusText}` + try { + const errBody = await res.text() + errorMsg += `\nResponse body: ${errBody}` + } + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) + } + // 6. Poll order status until 'valid' and certificate URL is present + let finalizedOrder: AcmeOrder = await res.json() + let attempts = 10 + while (finalizedOrder.status !== 'valid' && attempts-- > 0) { + await new Promise((r) => setTimeout(r, 2000)) + const pollRes = await fetch(order.finalize) + if (!pollRes.ok) throw new Error('Failed to poll order status') + finalizedOrder = await pollRes.json() + } + if (!finalizedOrder.certificate) throw new Error('Order did not finalize or no certificate URL') + // 7. Download certificate + const certRes = await fetch(finalizedOrder.certificate) + if (!certRes.ok) throw new Error('Failed to download certificate') + const certPem = await certRes.text() + return certPem + } + + /** + * Generate a new ECDSA private key and CSR for the given domains + * @param domains Array of domain names (first is CN, rest are SANs) + * @returns { privateKeyPem, csrPem } + */ + async generateCsr(domains: string[]): Promise<{ privateKeyPem: string, csrPem: string }> { + return generateCsr(domains) + } + + /** + * Download a certificate from a given URL + * @param certificateUrl The URL to the certificate resource + * @returns The certificate in PEM format (string) + */ + async downloadCertificate(certificateUrl: string): Promise { + const res = await fetch(certificateUrl) + if (!res.ok) { + let errorMsg = `Failed to download certificate: ${res.status} ${res.statusText}` + try { + const errBody = await res.text() + errorMsg += `\nResponse body: ${errBody}` + } + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) + } + return await res.text() + } + + // TODO: Implement certificate downloading +} diff --git a/old/certUtils.ts b/old/certUtils.ts new file mode 100644 index 0000000..31e2700 --- /dev/null +++ b/old/certUtils.ts @@ -0,0 +1,26 @@ +import { generateKeyPair } from 'crypto' +import { createSign } from 'crypto' + +/** + * Generate a new ECDSA (P-256) private key and CSR for the given domains. + * @param domains Array of domain names (first is CN, rest are SANs) + * @returns { privateKeyPem: string, csrPem: string } + */ +export async function generateCsr(domains: string[]): Promise<{ privateKeyPem: string, csrPem: string }> { + return new Promise((resolve, reject) => { + generateKeyPair('ec', { + namedCurve: 'P-256', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' } + }, (err, publicKey, privateKey) => { + if (err) return reject(err) + // Minimal CSR using node-forge or native crypto is non-trivial; stub for now + // You can use libraries like 'pkcs10' or 'node-forge' for real CSR generation + // Here, we return the private key and a placeholder CSR + resolve({ + privateKeyPem: privateKey, + csrPem: '-----BEGIN CERTIFICATE REQUEST-----\nMIIB...STUB...\n-----END CERTIFICATE REQUEST-----' + }) + }) + }) +} diff --git a/old/index.ts b/old/index.ts new file mode 100644 index 0000000..20eaaab --- /dev/null +++ b/old/index.ts @@ -0,0 +1,5 @@ +class ACMEClient { + +} + +export default ACMEClient diff --git a/src/client.ts b/src/client.ts index d0862db..38301fe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,20 +14,39 @@ export interface AcmeDirectory { } } +interface RetryConfig { + initialDelay: number + maxRetries: number + maxDelay: number + backoffFactor: number +} + export class AcmeClient { directoryUrl: string directory?: AcmeDirectory accountUrl?: string privateKey?: CryptoKey + retryConfig: RetryConfig - constructor(directoryUrl: string) { + constructor(directoryUrl: string, retryConfig: Partial = {}) { this.directoryUrl = directoryUrl + this.retryConfig = { + initialDelay: 1000, // 1 second + maxRetries: 5, + maxDelay: 30000, // 30 seconds + backoffFactor: 2, + ...retryConfig + } } async init(): Promise { await this.getDirectory() } + private async wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + private async getDirectory(): Promise { const response = await fetch(this.directoryUrl, { method: 'GET', @@ -62,6 +81,50 @@ export class AcmeClient { } } + private async canRetry(response?: Response): Promise { + // Retry on network error + if (!response) return true + // Retry on server errors (5xx), client errors (400), or rate limiting (429), bad nonce (400) will be handled in retry logic + if (response.status >= 500 || response.status === 400 || response.status === 429) return true + return false + } + + private async withRetry(operation: () => Promise, operationName: string): Promise { + let lastError: Error | undefined + let delay = this.retryConfig.initialDelay + for (let attempt = 0; attempt < this.retryConfig.maxRetries; attempt++) { + try { + return await operation() + } + catch (error) { + lastError = error as Error + // If this is the last attempt, throw the error + if (attempt === this.retryConfig.maxRetries) throw error + + // If we can retry, wait and try again + const response = (error as Error & { response?: Response }).response + if (!this.canRetry(response)) { + throw error + } + + // For bad nonce errors, we need to get a fresh nonce + if (response?.status === 400) { + const errorText = await response.text().catch(() => '') + if (!errorText.toLowerCase().includes('nonce')) { + throw error + } + } + + console.warn(`${operationName} failed (attempt ${attempt + 1}/${this.retryConfig.maxRetries + 1}), retrying in ${delay}ms:`, lastError.message) + + await this.wait(delay) + delay = Math.min(delay * this.retryConfig.backoffFactor, this.retryConfig.maxDelay) + } + } + // This should never be reached due to the logic above, but TypeScript needs this + throw lastError || new Error('Unknown error occurred during retry') + } + private async getNonce(): Promise { const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') @@ -87,51 +150,54 @@ export class AcmeClient { termsOfServiceAgreed: true, contact: [ `mailto:${email}` ] } - const protectedHeader = { - alg: 'ES256', - nonce: await this.getNonce(), - url: this.directory!.newAccount, - jwk - } - const encoder = new TextEncoder() - const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) - .setProtectedHeader(protectedHeader) - .sign(privateKey) - const jwsBody = JSON.stringify(jws) - const res = await fetch(this.directory!.newAccount, { - method: 'POST', - headers: { 'Content-Type': 'application/jose+json' }, - body: jwsBody - }) - if (!res.ok) { - let errorMsg = `Failed to create ACME account: ${res.status} ${res.statusText}` - try { - const errBody = await res.text() - errorMsg += `\nResponse body: ${errBody}` - } - catch (e: unknown) { - errorMsg += '\n(No response body)' - console.error('Error reading response body:', e) + + return await this.withRetry(async () => { + const protectedHeader = { + alg: 'ES256', + nonce: await this.getNonce(), + url: this.directory!.newAccount, + jwk } - throw new Error(errorMsg) - } - const accountUrl = res.headers.get('Location') - if (!accountUrl) { - let errorMsg = 'No account URL returned by ACME server.' - try { - const respBody = await res.text() - errorMsg += `\nResponse body: ${respBody}` + const encoder = new TextEncoder() + const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) + .setProtectedHeader(protectedHeader) + .sign(privateKey) + const jwsBody = JSON.stringify(jws) + const res = await fetch(this.directory!.newAccount, { + method: 'POST', + headers: { 'Content-Type': 'application/jose+json' }, + body: jwsBody + }) + if (!res.ok) { + let errorMsg = `Failed to create ACME account: ${res.status} ${res.statusText}` + try { + const errBody = await res.text() + errorMsg += `\nResponse body: ${errBody}` + } + catch (e: unknown) { + errorMsg += '\n(No response body)' + console.error('Error reading response body:', e) + } + throw new Error(errorMsg) } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - catch (e) { - errorMsg += '\n(No response body)' + const accountUrl = res.headers.get('Location') + if (!accountUrl) { + let errorMsg = 'No account URL returned by ACME server.' + try { + const respBody = await res.text() + errorMsg += `\nResponse body: ${respBody}` + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + catch (e) { + errorMsg += '\n(No response body)' + } + throw new Error(errorMsg) } - throw new Error(errorMsg) - } - // Store for this instance - this.accountUrl = accountUrl - this.privateKey = privateKey + // Store for this instance + this.accountUrl = accountUrl + this.privateKey = privateKey - return { accountUrl, privateKey } + return { accountUrl, privateKey } + }, 'createAccount') } } diff --git a/test/config/pebble-config.json b/test/config/pebble-config.json new file mode 100644 index 0000000..e69de29 diff --git a/test/globalSetup.ts b/test/globalSetup.ts index 748b653..4dc72bd 100644 --- a/test/globalSetup.ts +++ b/test/globalSetup.ts @@ -9,12 +9,14 @@ let startedComposeEnvironment: StartedDockerComposeEnvironment | undefined = und export async function setup(project: TestProject) { const composeEnvironment: DockerComposeEnvironment = await new DockerComposeEnvironment(composeFilePath, composeFile) .withWaitStrategy('pebble-1', Wait.forLogMessage('ACME directory available at: https://0.0.0.0:14000/dir')) + .withWaitStrategy('pebble-retry-1', Wait.forLogMessage('ACME directory available at: https://0.0.0.0:14000/dir')) .withWaitStrategy('challtestsrv-1', Wait.forLogMessage('Starting challenge servers')) startedComposeEnvironment = await composeEnvironment.up() project.provide('ACME_API', 'https://localhost:14000/dir') project.provide('ACME_MGMT_API', 'https://localhost:15000') project.provide('ACME_CHALLENGE_API', 'http://localhost:8055') + project.provide('ACME_API_RETRY', 'https://localhost:16000/dir') } export async function teardown() { @@ -25,6 +27,7 @@ declare module 'vitest' { export interface ProvidedContext { ACME_API: string ACME_MGMT_API: string + ACME_API_RETRY: string ACME_CHALLENGE_API: string } } diff --git a/test/retry.test.ts b/test/retry.test.ts new file mode 100644 index 0000000..1164cd3 --- /dev/null +++ b/test/retry.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, inject } from 'vitest' +import AcmeClient from '../src' + +describe('retry logic', () => { + const retryConfig = { maxDelay: 1000, initialDelay: 500, maxRetries: 50 } + describe('config', () => { + it('should have defaults set correctly', () => { + const client = new AcmeClient(inject('ACME_API_RETRY')) + expect(client.retryConfig.maxRetries).toBe(5) + expect(client.retryConfig.initialDelay).toBe(1000) + expect(client.retryConfig.maxDelay).toBe(30000) + }) + it('should allow custom retry configuration', () => { + const customConfig = { + initialDelay: 500, + maxRetries: 3, + maxDelay: 10000, + backoffFactor: 1.5 + } + const client = new AcmeClient(inject('ACME_API_RETRY'), customConfig) + expect(client.retryConfig.initialDelay).toBe(500) + expect(client.retryConfig.maxRetries).toBe(3) + expect(client.retryConfig.maxDelay).toBe(10000) + expect(client.retryConfig.backoffFactor).toBe(1.5) + }) + }) + describe('createAccount', () => { + it('should retry on network errors', async () => { + const client = new AcmeClient(inject('ACME_API_RETRY'), retryConfig) + client.init() + const { accountUrl, privateKey } = await client.createAccount('retry@voidrot.dev') + expect(typeof accountUrl).toBe('string') + expect(accountUrl.startsWith('https://')).toBe(true) + expect(privateKey).toBeDefined() + }, { timeout: 30000 }) // Increased timeout for retries + }) +}) From 3cf832d407253d3981cae3fc4aa46fc0eec535aa Mon Sep 17 00:00:00 2001 From: Buck Brady <22723438+voidrot@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:04:40 -0600 Subject: [PATCH 02/10] remove old code --- docs/README.md | 32 ---- docs/examples.md | 29 ---- old/acmeClient.ts | 381 ---------------------------------------------- old/certUtils.ts | 26 ---- old/index.ts | 5 - 5 files changed, 473 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/examples.md delete mode 100644 old/acmeClient.ts delete mode 100644 old/certUtils.ts delete mode 100644 old/index.ts diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f41f25e..0000000 --- a/docs/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# ACME Client Documentation - -This project provides a TypeScript ACME client for interacting with ACME servers (such as Let's Encrypt) to automate certificate issuance. - -## Features -- ACME directory discovery -- Account registration (with JWS/JWK) -- (Planned) Order creation, challenge handling, certificate download - -## Usage Example - -``` -ts -import { AcmeClient } from '../src/acmeClient' - -async function main() { - const acme = new AcmeClient('https://acme-v02.api.letsencrypt.org/directory') - const accountUrl = await acme.createAccount('your@email.com') - console.log('Account URL:', accountUrl) -} - -main().catch(console.error) -``` - -## API - -### `AcmeClient` -- `constructor(directoryUrl: string)` -- `getDirectory(): Promise` -- `createAccount(email: string): Promise` - -See the source for more details. diff --git a/docs/examples.md b/docs/examples.md deleted file mode 100644 index a7fd3ec..0000000 --- a/docs/examples.md +++ /dev/null @@ -1,29 +0,0 @@ -# ACME Client Examples - -## Register an Account - -````ts -import { AcmeClient } from '../src/acmeClient' - -async function main() { - const acme = new AcmeClient('https://acme-staging-v02.api.letsencrypt.org/directory') - const accountUrl = await acme.createAccount('your@email.com') - console.log('Account URL:', accountUrl) -} - -main().catch(console.error) -```` - -## Fetch Directory - -````ts -import { AcmeClient } from '../src/acmeClient' - -async function main() { - const acme = new AcmeClient('https://acme-staging-v02.api.letsencrypt.org/directory') - const dir = await acme.getDirectory() - console.log('Directory:', dir) -} - -main().catch(console.error) -```` diff --git a/old/acmeClient.ts b/old/acmeClient.ts deleted file mode 100644 index 4ef8482..0000000 --- a/old/acmeClient.ts +++ /dev/null @@ -1,381 +0,0 @@ -// ACME Client for Node.js (TypeScript) -// Basic structure for interfacing with ACME servers (e.g., Let's Encrypt) -// This is a starting point and can be extended for full ACME flows. - -import type { KeyLike } from 'jose' -import { generateKeyPair, exportJWK, FlattenedSign } from 'jose' -import { generateCsr } from './certUtils' - -export interface AcmeDirectory { - newNonce: string - newAccount: string - newOrder: string - revokeCert: string - keyChange: string -} - -export interface AcmeOrder { - status: string - expires?: string - identifiers: { type: string, value: string }[] - authorizations: string[] - finalize: string - certificate?: string -} - -export interface AcmeAuthorization { - status: string - identifier: { type: string, value: string } - challenges: AcmeChallenge[] -} - -export interface AcmeChallenge { - type: string - status: string - url: string - token: string -} - -export interface DnsProvider { - setRecord(domain: string, recordName: string, value: string): Promise - removeRecord(domain: string, recordName: string): Promise -} - -export class AcmeClient { - directoryUrl: string - directory?: AcmeDirectory - private accountUrl?: string - private privateKey?: KeyLike - private dnsProvider?: DnsProvider - - constructor(directoryUrl: string) { - this.directoryUrl = directoryUrl - } - - async getDirectory(): Promise { - const res = await fetch(this.directoryUrl) - if (!res.ok) throw new Error('Failed to fetch ACME directory') - const data = await res.json() as Record - if (!data['newNonce'] || !data['newAccount'] || !data['newOrder'] || !data['revokeCert'] || !data['keyChange']) { - throw new Error('Incomplete ACME directory response') - } - this.directory = { - newNonce: data['newNonce'], - newAccount: data['newAccount'], - newOrder: data['newOrder'], - revokeCert: data['revokeCert'], - keyChange: data['keyChange'] - } - return this.directory - } - - /** - * Create a new ACME account (register a key pair with the server) - * @param email Contact email for the account - * @returns The account URL (kid) and privateKey (for durable storage) - */ - async createAccount(email: string): Promise<{ accountUrl: string, privateKey: KeyLike }> { - if (!this.directory) await this.getDirectory() - // 1. Fetch a fresh nonce - const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) - const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') - if (!nonce) throw new Error('Failed to get ACME nonce') - - // 2. Generate a key pair (ES256) - const { publicKey, privateKey } = await generateKeyPair('ES256') - const jwk = await exportJWK(publicKey) - delete (jwk as Record).key_ops - delete (jwk as Record).ext - - // 3. Build JWS-protected payload for account creation (ACME expects JWS JSON serialization) - const payload = { - termsOfServiceAgreed: true, - contact: [ `mailto:${email}` ] - } - const protectedHeader = { - alg: 'ES256', - nonce, - url: this.directory!.newAccount, - jwk - } - // Use FlattenedSign from jose to create the JWS JSON serialization - const encoder = new TextEncoder() - const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) - .setProtectedHeader(protectedHeader) - .sign(privateKey) - // jws is an object: { protected, payload, signature } - const jwsBody = JSON.stringify(jws) - - // 4. Send account creation request - const res = await fetch(this.directory!.newAccount, { - method: 'POST', - headers: { 'Content-Type': 'application/jose+json' }, - body: jwsBody - }) - if (!res.ok) { - let errorMsg = `Failed to create ACME account: ${res.status} ${res.statusText}` - try { - const errBody = await res.text() - errorMsg += `\nResponse body: ${errBody}` - } - catch (e) { - errorMsg += '\n(No response body)' - } - throw new Error(errorMsg) - } - // The account URL (kid) is in the Location header - const accountUrl = res.headers.get('Location') - if (!accountUrl) { - let errorMsg = 'No account URL returned by ACME server.' - try { - const respBody = await res.text() - errorMsg += `\nResponse body: ${respBody}` - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - catch (e) { - errorMsg += '\n(No response body)' - } - throw new Error(errorMsg) - } - // Store for this instance - this.accountUrl = accountUrl - this.privateKey = privateKey - // Return for durable storage - return { accountUrl, privateKey } - } - - /** - * Restore account state for authenticated requests - */ - restoreAccount(accountUrl: string, privateKey: KeyLike) { - this.accountUrl = accountUrl - this.privateKey = privateKey - } - - /** - * Create a new order for a certificate - * @param domains Array of domain names to include in the certificate - * @returns The ACME order object - */ - async orderCertificate(domains: string[]): Promise { - if (!this.directory) await this.getDirectory() - if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set. Call createAccount or restoreAccount first.') - // 1. Fetch a fresh nonce - const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) - const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') - if (!nonce) throw new Error('Failed to get ACME nonce') - - // 2. Prepare payload - const identifiers = domains.map((d) => ({ type: 'dns', value: d })) - const payload = { identifiers } - // 3. Build protected header (use kid/account URL for authenticated requests) - const protectedHeader = { - alg: 'ES256', - kid: this.accountUrl, - nonce, - url: this.directory!.newOrder - } - // 4. Sign with FlattenedSign - const encoder = new TextEncoder() - const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) - .setProtectedHeader(protectedHeader) - .sign(this.privateKey) - const jwsBody = JSON.stringify(jws) - - // 5. Send order request - const res = await fetch(this.directory!.newOrder, { - method: 'POST', - headers: { 'Content-Type': 'application/jose+json' }, - body: jwsBody - }) - if (!res.ok) { - let errorMsg = `Failed to create ACME order: ${res.status} ${res.statusText}` - try { - const errBody = await res.text() - errorMsg += `\nResponse body: ${errBody}` - } - catch (e) { - errorMsg += '\n(No response body)' - } - throw new Error(errorMsg) - } - const order: AcmeOrder = await res.json() - return order - } - - setDnsProvider(provider: DnsProvider) { - this.dnsProvider = provider - } - - /** - * Complete DNS-01 challenges for all authorizations in an order - * @param order The ACME order object - * @returns Array of completed authorization URLs - */ - async completeDns01Challenges(order: AcmeOrder): Promise { - if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set. Call createAccount or restoreAccount first.') - if (!this.dnsProvider) throw new Error('DNS provider not set. Call setDnsProvider first.') - // 1. Fetch all authorizations - const completedAuthz: string[] = [] - for (const authzUrl of order.authorizations) { - const authzRes = await fetch(authzUrl) - if (!authzRes.ok) throw new Error(`Failed to fetch authorization: ${authzUrl}`) - const authz: AcmeAuthorization = await authzRes.json() - if (authz.status === 'valid') { - completedAuthz.push(authzUrl) - continue - } - // 2. Find the dns-01 challenge - const challenge = authz.challenges.find((c) => c.type === 'dns-01') - if (!challenge) throw new Error(`No dns-01 challenge for ${authz.identifier.value}`) - // 3. Compute key authorization (stub, real implementation needs JWK thumbprint) - const keyAuthorization = challenge.token + '.KEY_THUMBPRINT_STUB' - // 4. Set DNS record via provider - const recordName = `_acme-challenge.${authz.identifier.value}` - await this.dnsProvider.setRecord(authz.identifier.value, recordName, keyAuthorization) - // 5. Notify ACME server to validate - await this.respondToChallenge(challenge.url, keyAuthorization) - // 6. Wait for challenge to be valid (polling, stubbed as immediate) - completedAuthz.push(authzUrl) - } - return completedAuthz - } - - /** - * Respond to a challenge (POST empty payload to challenge URL) - */ - private async respondToChallenge(challengeUrl: string, keyAuthorization: string) { - if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set.') - if (!this.directory) await this.getDirectory() - // 1. Fetch a fresh nonce - const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) - const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') - if (!nonce) throw new Error('Failed to get ACME nonce') - // 2. Build protected header - const protectedHeader = { - alg: 'ES256', - kid: this.accountUrl, - nonce, - url: challengeUrl - } - // 3. Sign empty payload - const encoder = new TextEncoder() - const jws = await new FlattenedSign(encoder.encode('')) - .setProtectedHeader(protectedHeader) - .sign(this.privateKey) - const jwsBody = JSON.stringify(jws) - // 4. POST to challenge URL - const res = await fetch(challengeUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/jose+json' }, - body: jwsBody - }) - if (!res.ok) { - let errorMsg = `Failed to respond to challenge: ${res.status} ${res.statusText}` - try { - const errBody = await res.text() - errorMsg += `\nResponse body: ${errBody}` - } - catch (e) { - errorMsg += '\n(No response body)' - } - throw new Error(errorMsg) - } - } - - /** - * Finalize the order and download the certificate - * @param order The ACME order object - * @param csrPem The CSR in PEM format (string) - * @returns The issued certificate (PEM string) - */ - async finalizeOrder(order: AcmeOrder, csrPem: string): Promise { - if (!this.accountUrl || !this.privateKey) throw new Error('Account state not set. Call createAccount or restoreAccount first.') - if (!this.directory) await this.getDirectory() - // 1. Fetch a fresh nonce - const nonceRes = await fetch(this.directory!.newNonce, { method: 'HEAD' }) - const nonce = nonceRes.headers.get('Replay-Nonce') || nonceRes.headers.get('replay-nonce') - if (!nonce) throw new Error('Failed to get ACME nonce') - // 2. Prepare payload (base64url-encoded DER CSR, no PEM headers) - const csrDer = Buffer.from(csrPem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, ''), 'base64') - const csrB64 = csrDer.toString('base64url') - const payload = { csr: csrB64 } - // 3. Build protected header - const protectedHeader = { - alg: 'ES256', - kid: this.accountUrl, - nonce, - url: order.finalize - } - // 4. Sign with FlattenedSign - const encoder = new TextEncoder() - const jws = await new FlattenedSign(encoder.encode(JSON.stringify(payload))) - .setProtectedHeader(protectedHeader) - .sign(this.privateKey) - const jwsBody = JSON.stringify(jws) - // 5. POST to finalize URL - const res = await fetch(order.finalize, { - method: 'POST', - headers: { 'Content-Type': 'application/jose+json' }, - body: jwsBody - }) - if (!res.ok) { - let errorMsg = `Failed to finalize ACME order: ${res.status} ${res.statusText}` - try { - const errBody = await res.text() - errorMsg += `\nResponse body: ${errBody}` - } - catch (e) { - errorMsg += '\n(No response body)' - } - throw new Error(errorMsg) - } - // 6. Poll order status until 'valid' and certificate URL is present - let finalizedOrder: AcmeOrder = await res.json() - let attempts = 10 - while (finalizedOrder.status !== 'valid' && attempts-- > 0) { - await new Promise((r) => setTimeout(r, 2000)) - const pollRes = await fetch(order.finalize) - if (!pollRes.ok) throw new Error('Failed to poll order status') - finalizedOrder = await pollRes.json() - } - if (!finalizedOrder.certificate) throw new Error('Order did not finalize or no certificate URL') - // 7. Download certificate - const certRes = await fetch(finalizedOrder.certificate) - if (!certRes.ok) throw new Error('Failed to download certificate') - const certPem = await certRes.text() - return certPem - } - - /** - * Generate a new ECDSA private key and CSR for the given domains - * @param domains Array of domain names (first is CN, rest are SANs) - * @returns { privateKeyPem, csrPem } - */ - async generateCsr(domains: string[]): Promise<{ privateKeyPem: string, csrPem: string }> { - return generateCsr(domains) - } - - /** - * Download a certificate from a given URL - * @param certificateUrl The URL to the certificate resource - * @returns The certificate in PEM format (string) - */ - async downloadCertificate(certificateUrl: string): Promise { - const res = await fetch(certificateUrl) - if (!res.ok) { - let errorMsg = `Failed to download certificate: ${res.status} ${res.statusText}` - try { - const errBody = await res.text() - errorMsg += `\nResponse body: ${errBody}` - } - catch (e) { - errorMsg += '\n(No response body)' - } - throw new Error(errorMsg) - } - return await res.text() - } - - // TODO: Implement certificate downloading -} diff --git a/old/certUtils.ts b/old/certUtils.ts deleted file mode 100644 index 31e2700..0000000 --- a/old/certUtils.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { generateKeyPair } from 'crypto' -import { createSign } from 'crypto' - -/** - * Generate a new ECDSA (P-256) private key and CSR for the given domains. - * @param domains Array of domain names (first is CN, rest are SANs) - * @returns { privateKeyPem: string, csrPem: string } - */ -export async function generateCsr(domains: string[]): Promise<{ privateKeyPem: string, csrPem: string }> { - return new Promise((resolve, reject) => { - generateKeyPair('ec', { - namedCurve: 'P-256', - publicKeyEncoding: { type: 'spki', format: 'pem' }, - privateKeyEncoding: { type: 'pkcs8', format: 'pem' } - }, (err, publicKey, privateKey) => { - if (err) return reject(err) - // Minimal CSR using node-forge or native crypto is non-trivial; stub for now - // You can use libraries like 'pkcs10' or 'node-forge' for real CSR generation - // Here, we return the private key and a placeholder CSR - resolve({ - privateKeyPem: privateKey, - csrPem: '-----BEGIN CERTIFICATE REQUEST-----\nMIIB...STUB...\n-----END CERTIFICATE REQUEST-----' - }) - }) - }) -} diff --git a/old/index.ts b/old/index.ts deleted file mode 100644 index 20eaaab..0000000 --- a/old/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -class ACMEClient { - -} - -export default ACMEClient From 0cfd19b2eec35370857aa4e54a5b994af3a39f7d Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Mon, 7 Jul 2025 18:05:59 -0600 Subject: [PATCH 03/10] temp .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9a5679c..3e654cb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ *.tsbuildinfo .env .vscode +old +docs From f43dca9822c5302ecd181a5a57eb1ca78ab7cb49 Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Mon, 7 Jul 2025 18:36:52 -0600 Subject: [PATCH 04/10] add support for selecting profiles --- src/acmeServers.ts | 2 ++ src/certUtils.ts | 5 +++++ src/client.ts | 27 ++++++++++++++++++++++++++- test/certs.test.ts | 7 +++++++ test/client.test.ts | 19 +++++++++++++++++++ test/config/pebble-config.json | 0 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/acmeServers.ts create mode 100644 src/certUtils.ts create mode 100644 test/certs.test.ts delete mode 100644 test/config/pebble-config.json diff --git a/src/acmeServers.ts b/src/acmeServers.ts new file mode 100644 index 0000000..f809c5b --- /dev/null +++ b/src/acmeServers.ts @@ -0,0 +1,2 @@ +const LETS_ENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory' +const LETS_ENCRYPT_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory' diff --git a/src/certUtils.ts b/src/certUtils.ts new file mode 100644 index 0000000..edaf7c9 --- /dev/null +++ b/src/certUtils.ts @@ -0,0 +1,5 @@ +export const generateCSR = async (): Promise => { + // Placeholder for CSR generation logic + // In a real implementation, you would use a library like 'node-forge' or 'pkcs10' + return '-----BEGIN CERTIFICATE REQUEST-----\nMIIB...STUB...\n-----END CERTIFICATE REQUEST-----'; +} diff --git a/src/client.ts b/src/client.ts index 38301fe..6959e29 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,6 +11,7 @@ export interface AcmeDirectory { website?: string caaIdentities?: string[] externalAccountRequired?: boolean + profiles?: string[] } } @@ -24,6 +25,7 @@ interface RetryConfig { export class AcmeClient { directoryUrl: string directory?: AcmeDirectory + directoryProfile?: string accountUrl?: string privateKey?: CryptoKey retryConfig: RetryConfig @@ -41,6 +43,7 @@ export class AcmeClient { async init(): Promise { await this.getDirectory() + // TODO: check if profiles are supported and set default profile if needed. if available, set default profile to 'tlsserver' or similar } private async wait(ms: number): Promise { @@ -66,6 +69,11 @@ export class AcmeClient { throw new Error('Incomplete ACME directory response') } + let profiles: string[] | undefined + if (data['meta']['profiles']) { + profiles = Object.keys(data['meta']['profiles']) + } + this.directory = { newNonce: data['newNonce'], newAccount: data['newAccount'], @@ -76,7 +84,8 @@ export class AcmeClient { termsOfService: data['meta']['termsOfService'], website: data['meta']?.website, caaIdentities: data['meta']?.caaIdentities || [], - externalAccountRequired: data['meta']?.externalAccountRequired || false + externalAccountRequired: data['meta']?.externalAccountRequired || false, + profiles } } } @@ -137,6 +146,22 @@ export class AcmeClient { this.privateKey = privateKey } + async showProfiles(): Promise { + if (!this.directory) await this.getDirectory() + if (!this.directory!.meta.profiles) { + throw new Error('No profiles available in ACME directory') + } + return this.directory!.meta.profiles + } + + async setProfile(profile: string): Promise { + if (!this.directory) await this.getDirectory() + if (!this.directory!.meta.profiles || !this.directory!.meta.profiles.includes(profile)) { + throw new Error(`Profile "${profile}" not found in ACME directory`) + } + this.directoryProfile = profile + } + async createAccount(email: string): Promise<{ accountUrl: string, privateKey: CryptoKey }> { // Ensure directory is initialized incase `init()` was not called if (!this.directory) await this.getDirectory() diff --git a/test/certs.test.ts b/test/certs.test.ts new file mode 100644 index 0000000..46c313f --- /dev/null +++ b/test/certs.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect, inject } from 'vitest' + +describe('certificate utilities', () => { + it('placeholder test', () => { + expect(true).toBe(true) // Replace with actual tests for certificate utilities + }) +}) diff --git a/test/client.test.ts b/test/client.test.ts index 2f9f59d..e7a737c 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -48,6 +48,25 @@ describe('node-acme', () => { expect(client.directory).toHaveProperty('newOrder') expect(client.directory).toHaveProperty('revokeCert') expect(client.directory).toHaveProperty('keyChange') + expect(client.directory).toHaveProperty('meta') + }) + it('should handle directory with profiles', async () => { + const client = new AcmeClient(inject('ACME_API')) + await client.init() + expect(client.directory!.meta.profiles).toBeDefined() + expect(Array.isArray(client.directory!.meta.profiles)).toBe(true) + expect(client.directory!.meta.profiles!.length).toBeGreaterThan(0) + }) + it('should be able to set a directory profile', async () => { + const client = new AcmeClient(inject('ACME_API')) + await client.init() + await client.setProfile('default') + expect(client.directoryProfile).toBe('default') + }) + it('should throw an error if profile is not supported', async () => { + const client = new AcmeClient(inject('ACME_API')) + await client.init() + await expect(client.setProfile('unsupported-profile')).rejects.toThrow('Profile "unsupported-profile" not found in ACME directory') }) }) diff --git a/test/config/pebble-config.json b/test/config/pebble-config.json deleted file mode 100644 index e69de29..0000000 From 96cd3d814d6342109d2c252989b6ddf3b7ffd6b3 Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Mon, 7 Jul 2025 18:37:51 -0600 Subject: [PATCH 05/10] add export to known ACME servers --- src/acmeServers.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/acmeServers.ts b/src/acmeServers.ts index f809c5b..2a2ff87 100644 --- a/src/acmeServers.ts +++ b/src/acmeServers.ts @@ -1,2 +1,2 @@ -const LETS_ENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory' -const LETS_ENCRYPT_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory' +export const LETS_ENCRYPT_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory' +export const LETS_ENCRYPT_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory' From 05d1fb4833c0e7698651c73fa393290739dd37a2 Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Mon, 7 Jul 2025 18:38:42 -0600 Subject: [PATCH 06/10] add concurrency guard to test workflow --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db5f3ae..c51b1b2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,10 @@ on: branches: - '**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_EXTRA_CA_CERTS: './test/certs/' NODE_TLS_REJECT_UNAUTHORIZED: '0' From 177203a96dea9a2ef07e89e5b859f712d795e683 Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Mon, 7 Jul 2025 18:39:40 -0600 Subject: [PATCH 07/10] wip: fix placeholder for cert tests --- test/certs.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/certs.test.ts b/test/certs.test.ts index 46c313f..9a06f3c 100644 --- a/test/certs.test.ts +++ b/test/certs.test.ts @@ -3,5 +3,7 @@ import { describe, it, expect, inject } from 'vitest' describe('certificate utilities', () => { it('placeholder test', () => { expect(true).toBe(true) // Replace with actual tests for certificate utilities + const x = inject('ACME_API') + expect(x).toBeDefined() }) }) From 7e515be12297138a7ebbcdce92554b1763cbfc97 Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Tue, 8 Jul 2025 20:20:42 -0600 Subject: [PATCH 08/10] add dependabot --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5f0889c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From fb4cbb7515a0d9a9fed211570d12922dd11202c1 Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Tue, 8 Jul 2025 20:39:07 -0600 Subject: [PATCH 09/10] update permission on test workflow --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c51b1b2..7caac06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,10 @@ on: branches: - '**' +permissions: + contents: read + pull-requests: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From 350f0815f9415cef83176b1168815aa33ee4645a Mon Sep 17 00:00:00 2001 From: Buck Brady Date: Wed, 16 Jul 2025 23:57:17 -0600 Subject: [PATCH 10/10] fixing tests --- .gitignore | 1 + src/acmeClient.ts | 0 src/certUtils.ts | 2 +- src/client.ts | 12 ++++++++++++ src/dnsProviders/baseClient.ts | 3 +++ src/dnsProviders/index.ts | 1 + src/dnsProviders/pebble.ts | 5 +++++ src/index.ts | 3 +++ test/retry.test.ts | 4 ++-- 9 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 src/acmeClient.ts create mode 100644 src/dnsProviders/baseClient.ts create mode 100644 src/dnsProviders/index.ts create mode 100644 src/dnsProviders/pebble.ts diff --git a/.gitignore b/.gitignore index 3e654cb..de8a82a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .vscode old docs +.idea diff --git a/src/acmeClient.ts b/src/acmeClient.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/certUtils.ts b/src/certUtils.ts index edaf7c9..b7b3d79 100644 --- a/src/certUtils.ts +++ b/src/certUtils.ts @@ -1,5 +1,5 @@ export const generateCSR = async (): Promise => { // Placeholder for CSR generation logic // In a real implementation, you would use a library like 'node-forge' or 'pkcs10' - return '-----BEGIN CERTIFICATE REQUEST-----\nMIIB...STUB...\n-----END CERTIFICATE REQUEST-----'; + return '-----BEGIN CERTIFICATE REQUEST-----\nMIIB...STUB...\n-----END CERTIFICATE REQUEST-----' } diff --git a/src/client.ts b/src/client.ts index 6959e29..d349fa1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -225,4 +225,16 @@ export class AcmeClient { return { accountUrl, privateKey } }, 'createAccount') } + + async createOrder(): Promise { + throw new Error('createOrder not implemented yet') + } + + async revokeCertificate(): Promise { + throw new Error('revokeCertificate not implemented yet') + } + + async changeKey(): Promise { + throw new Error('changeKey not implemented yet') + } } diff --git a/src/dnsProviders/baseClient.ts b/src/dnsProviders/baseClient.ts new file mode 100644 index 0000000..170f718 --- /dev/null +++ b/src/dnsProviders/baseClient.ts @@ -0,0 +1,3 @@ +export abstract class BaseDNSProvider { + +} diff --git a/src/dnsProviders/index.ts b/src/dnsProviders/index.ts new file mode 100644 index 0000000..e641389 --- /dev/null +++ b/src/dnsProviders/index.ts @@ -0,0 +1 @@ +export * from './pebble' diff --git a/src/dnsProviders/pebble.ts b/src/dnsProviders/pebble.ts new file mode 100644 index 0000000..2dff907 --- /dev/null +++ b/src/dnsProviders/pebble.ts @@ -0,0 +1,5 @@ +import { BaseDNSProvider } from './baseClient' + +export class PebbleDNSProvider extends BaseDNSProvider { + +} diff --git a/src/index.ts b/src/index.ts index 21aa9a6..87d231e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ import { AcmeClient } from './client' +export * from './dnsProviders' +export * from './acmeServers' export * from './client' + export default AcmeClient diff --git a/test/retry.test.ts b/test/retry.test.ts index 1164cd3..d8a9998 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, inject } from 'vitest' import AcmeClient from '../src' describe('retry logic', () => { - const retryConfig = { maxDelay: 1000, initialDelay: 500, maxRetries: 50 } + const retryConfig = { maxDelay: 100, initialDelay: 100, maxRetries: 50 } describe('config', () => { it('should have defaults set correctly', () => { const client = new AcmeClient(inject('ACME_API_RETRY')) @@ -32,6 +32,6 @@ describe('retry logic', () => { expect(typeof accountUrl).toBe('string') expect(accountUrl.startsWith('https://')).toBe(true) expect(privateKey).toBeDefined() - }, { timeout: 30000 }) // Increased timeout for retries + }, { timeout: 60000 }) // Increased timeout for retries }) })