Skip to content
This repository was archived by the owner on Jul 26, 2025. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ dist/
*.log
*.tsbuildinfo
.env
.vscode
old
docs
.idea
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 (<development@buckbrady.com>)
>
> [GitHub](https://github.com/voidrot/node-acme)
18 changes: 18 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file added src/acmeClient.ts
Empty file.
2 changes: 2 additions & 0 deletions src/acmeServers.ts
Original file line number Diff line number Diff line change
@@ -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'
5 changes: 5 additions & 0 deletions src/certUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const generateCSR = async (): Promise<string> => {
// 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-----'
}
191 changes: 147 additions & 44 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RetryConfig> = {}) {
this.directoryUrl = directoryUrl
this.retryConfig = {
initialDelay: 1000, // 1 second
maxRetries: 5,
maxDelay: 30000, // 30 seconds
backoffFactor: 2,
...retryConfig
}
}

async init(): Promise<void> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

private async getDirectory(): Promise<void> {
Expand All @@ -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'],
Expand All @@ -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<boolean> {
// 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<T>(operation: () => Promise<T>, operationName: string): Promise<T> {
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<string> {
Expand All @@ -74,6 +146,22 @@ export class AcmeClient {
this.privateKey = privateKey
}

async showProfiles(): Promise<string[]> {
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<void> {
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()
Expand All @@ -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<void> {
throw new Error('createOrder not implemented yet')
}

async revokeCertificate(): Promise<void> {
throw new Error('revokeCertificate not implemented yet')
}

return { accountUrl, privateKey }
async changeKey(): Promise<void> {
throw new Error('changeKey not implemented yet')
}
}
3 changes: 3 additions & 0 deletions src/dnsProviders/baseClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export abstract class BaseDNSProvider {

}
1 change: 1 addition & 0 deletions src/dnsProviders/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './pebble'
5 changes: 5 additions & 0 deletions src/dnsProviders/pebble.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { BaseDNSProvider } from './baseClient'

export class PebbleDNSProvider extends BaseDNSProvider {

}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { AcmeClient } from './client'

export * from './dnsProviders'
export * from './acmeServers'
export * from './client'

export default AcmeClient
9 changes: 9 additions & 0 deletions test/certs.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading