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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db5f3ae..7caac06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,14 @@ on: branches: - '**' +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: NODE_EXTRA_CA_CERTS: './test/certs/' NODE_TLS_REJECT_UNAUTHORIZED: '0' diff --git a/.gitignore b/.gitignore index 3b3ee33..de8a82a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist/ *.log *.tsbuildinfo .env +.vscode +old +docs +.idea 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/src/acmeClient.ts b/src/acmeClient.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/acmeServers.ts b/src/acmeServers.ts new file mode 100644 index 0000000..2a2ff87 --- /dev/null +++ b/src/acmeServers.ts @@ -0,0 +1,2 @@ +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' diff --git a/src/certUtils.ts b/src/certUtils.ts new file mode 100644 index 0000000..b7b3d79 --- /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 d0862db..d349fa1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,21 +11,43 @@ export interface AcmeDirectory { website?: string caaIdentities?: string[] externalAccountRequired?: boolean + profiles?: string[] } } +interface RetryConfig { + initialDelay: number + maxRetries: number + maxDelay: number + backoffFactor: number +} + export class AcmeClient { directoryUrl: string directory?: AcmeDirectory + directoryProfile?: string 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() + // 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 { + return new Promise((resolve) => setTimeout(resolve, ms)) } private async getDirectory(): Promise { @@ -47,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'], @@ -57,9 +84,54 @@ 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 + } + } + } + + 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 { @@ -74,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() @@ -87,51 +175,66 @@ 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}` + + return await this.withRetry(async () => { + const protectedHeader = { + alg: 'ES256', + nonce: await this.getNonce(), + url: this.directory!.newAccount, + jwk } - catch (e: unknown) { - errorMsg += '\n(No response body)' - console.error('Error reading response body:', e) + 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) } - 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 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) } - // 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 + // Store for this instance + this.accountUrl = accountUrl + this.privateKey = privateKey + + return { accountUrl, privateKey } + }, 'createAccount') + } + + async createOrder(): Promise { + throw new Error('createOrder not implemented yet') + } + + async revokeCertificate(): Promise { + throw new Error('revokeCertificate not implemented yet') + } - return { accountUrl, privateKey } + 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/certs.test.ts b/test/certs.test.ts new file mode 100644 index 0000000..9a06f3c --- /dev/null +++ b/test/certs.test.ts @@ -0,0 +1,9 @@ +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() + }) +}) 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/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..d8a9998 --- /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: 100, initialDelay: 100, 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: 60000 }) // Increased timeout for retries + }) +})