Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab0ca6ccc3 | |||
| e570ac6db0 | |||
| 47168408cc | |||
| 74ad7cd6c4 | |||
| 77d40985f3 | |||
| adf9262ded | |||
| e2d182ca03 | |||
| 8cd713447e | |||
| 2cf3dbdd95 | |||
| 1c75bac44f | |||
| a865fb89e0 | |||
| cfc0695c8a | |||
| 68178366d5 | |||
| 52e1295fd2 |
56
changelog.md
56
changelog.md
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-19 - 9.3.0 - feat(readme)
|
||||
document built-in ACME directory server and CA capabilities
|
||||
|
||||
- Update package metadata to describe client and server support, including built-in CA functionality
|
||||
- Add README sections for ACME server quick start, configuration, endpoints, challenge verification, and trust setup
|
||||
- Expand architecture notes and project hints to cover ts_server modules and tsbuild tsfolders usage
|
||||
|
||||
## 2026-03-19 - 9.2.0 - feat(server)
|
||||
add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
|
||||
|
||||
- exports a new server module with AcmeServer, AcmeServerCA, server error types, and related interfaces
|
||||
- implements in-memory account and order storage, nonce management, JWS verification, routing, challenge validation, and CSR signing for RFC 8555 style flows
|
||||
- adds end-to-end tests for account creation, order processing, challenge handling, certificate issuance, and error scenarios
|
||||
- updates the build configuration to include tsfolders and package file patterns for ts_* sources
|
||||
|
||||
## 2026-02-16 - 9.1.3 - fix(smartacme)
|
||||
Include base domain alongside wildcard when building identifiers for wildcard certificate requests
|
||||
|
||||
- When isWildcardRequest is true, the base domain (e.g. example.com) is now added in addition to the wildcard (*.example.com) so the issued certificate covers both apex and wildcard entries.
|
||||
- Prevents missing SAN for the apex domain when requesting wildcard certificates.
|
||||
|
||||
## 2026-02-15 - 9.1.2 - fix(docs)
|
||||
document built-in concurrency control, rate limiting, and request deduplication in README
|
||||
|
||||
- Added a new 'Concurrency Control & Rate Limiting' section to the README describing per-domain mutex, global concurrency cap, and sliding-window account rate limiting (defaults: 1 per domain, 5 global, 250 per 3 hours).
|
||||
- Documented new SmartAcme options in the interface: maxConcurrentIssuances, maxOrdersPerWindow, and orderWindowMs.
|
||||
- Added example code showing configuration of the limits and an example of request deduplication behavior (multiple subdomain requests resolving to a single ACME order).
|
||||
- Added an example subscription to certIssuanceEvents and updated the components table with TaskManager entry.
|
||||
- Change is documentation-only (README) — no code changes; safe patch release.
|
||||
|
||||
## 2026-02-15 - 9.1.1 - fix(deps)
|
||||
bump @push.rocks/smarttime to ^4.2.3 and @push.rocks/taskbuffer to ^6.1.2
|
||||
|
||||
- @push.rocks/smarttime: ^4.1.1 -> ^4.2.3
|
||||
- @push.rocks/taskbuffer: ^6.1.0 -> ^6.1.2
|
||||
- Only package.json dependency version updates; no code changes
|
||||
|
||||
## 2026-02-15 - 9.1.0 - feat(smartacme)
|
||||
Integrate @push.rocks/taskbuffer TaskManager to coordinate ACME certificate issuance with per-domain mutex, global concurrency cap, and account-level rate limiting; refactor issuance flow into a single reusable cert-issuance task, expose issuance events, and update lifecycle to start/stop the TaskManager. Add configuration for concurrent issuances and sliding-window order limits, export taskbuffer types/plugins, and update tests and docs accordingly.
|
||||
|
||||
- Added dependency @push.rocks/taskbuffer and re-exported ITaskEvent/ITaskMetadata in ts/index.ts; also imported/exported taskbuffer in ts/plugins.ts.
|
||||
- Replaced interestMap coordination with TaskManager + TaskConstraintGroup(s): 'cert-domain-mutex' (per-domain mutex, resultSharingMode: 'share-latest'), 'acme-global-concurrency' (global concurrency cap), and 'acme-account-rate-limit' (sliding-window rate limiter).
|
||||
- Introduced a single reusable Task named 'cert-issuance' and moved the ACME issuance flow into performCertificateIssuance(), splitting progress into named steps (prepare/authorize/finalize/store) and using notifyStep() for observable progress.
|
||||
- Exposed certIssuanceEvents via SmartAcme.certIssuanceEvents and wired TaskManager.start()/stop() into SmartAcme.start()/stop().
|
||||
- Added new ISmartAcmeOptions: maxConcurrentIssuances, maxOrdersPerWindow, orderWindowMs to control concurrency and rate limiting.
|
||||
- Updated tests to remove interestMap stubs and adapt to the taskbuffer-based flow; cleaned up client/retry stubbing in tests.
|
||||
- Updated readme.hints.md with guidance on concurrency, rate limiting, and taskbuffer integration.
|
||||
|
||||
## 2026-02-15 - 9.0.1 - fix(acme-http-client)
|
||||
Destroy keep-alive HTTP agents and DNS client on shutdown to allow process exit; add destroy() on AcmeHttpClient and AcmeClient, wire agents into requests, and call client/smartdns destroy during SmartAcme.stop; documentation clarifications and expanded README (error handling, examples, default retry values).
|
||||
|
||||
- ts/acme/acme.classes.http-client.ts: added per-protocol http/https agents (keepAlive: false), use agent for outgoing requests, and added destroy() to explicitly destroy agents and free sockets.
|
||||
- ts/acme/acme.classes.client.ts: added destroy() that forwards to the HTTP client to allow transport cleanup.
|
||||
- ts/smartacme.classes.smartacme.ts: SmartAcme.stop now calls client.destroy() and smartdns.destroy() (when present) to ensure child processes and sockets are terminated before exit; also ensures certmanager.close() is awaited.
|
||||
- readme.md: documentation improvements and clarifications (Let’s Encrypt spelling, added RFC 8555 compliance note, error handling / AcmeError usage examples, default retry parameter docs, UI/emoji improvements, and other wording/formatting updates).
|
||||
|
||||
## 2026-02-15 - 9.0.0 - BREAKING CHANGE(acme)
|
||||
Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly
|
||||
|
||||
|
||||
34
package.json
34
package.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@push.rocks/smartacme",
|
||||
"version": "9.0.0",
|
||||
"version": "9.3.0",
|
||||
"private": false,
|
||||
"description": "A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.",
|
||||
"description": "A TypeScript-based ACME client and server for certificate management with built-in CA, supporting LetsEncrypt and custom ACME authorities.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "(tstest test/ --verbose --logfile --timeout 600)",
|
||||
"build": "(tsbuild)",
|
||||
"build": "(tsbuild tsfolders)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"repository": {
|
||||
@@ -20,6 +20,9 @@
|
||||
"LetsEncrypt",
|
||||
"TypeScript",
|
||||
"certificate management",
|
||||
"certificate authority",
|
||||
"ACME server",
|
||||
"PKI",
|
||||
"DNS challenges",
|
||||
"SSL/TLS",
|
||||
"secure communication",
|
||||
@@ -28,9 +31,11 @@
|
||||
"crypto",
|
||||
"MongoDB",
|
||||
"dns-01 challenge",
|
||||
"http-01 challenge",
|
||||
"token-based challenges",
|
||||
"certificate renewal",
|
||||
"wildcard certificates"
|
||||
"wildcard certificates",
|
||||
"RFC 8555"
|
||||
],
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
@@ -41,27 +46,28 @@
|
||||
"dependencies": {
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@peculiar/x509": "^1.14.3",
|
||||
"@push.rocks/lik": "^6.2.2",
|
||||
"@push.rocks/smartdata": "^7.0.15",
|
||||
"@push.rocks/lik": "^6.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.0",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartdns": "^7.8.1",
|
||||
"@push.rocks/smartlog": "^3.1.10",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@tsclass/tsclass": "^9.3.0"
|
||||
"@push.rocks/taskbuffer": "^6.1.2",
|
||||
"@tsclass/tsclass": "^9.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.1.2",
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.1.8",
|
||||
"@git.zone/tstest": "^3.4.0",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@types/node": "^25.2.3"
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
"ts_*/**/*",
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
|
||||
7563
pnpm-lock.yaml
generated
7563
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,49 @@ Key files:
|
||||
|
||||
Usage in `ts/plugins.ts`: `import * as acme from './acme/index.js'` (replaces `acme-client`)
|
||||
|
||||
## Concurrency & Rate Limiting (taskbuffer integration)
|
||||
|
||||
As of v9.1.0, `@push.rocks/lik.InterestMap` was replaced with `@push.rocks/taskbuffer.TaskManager` for coordinating concurrent certificate requests. This provides:
|
||||
|
||||
- **Per-domain mutex** (`cert-domain-mutex`): Only one ACME issuance per TLD at a time, with `resultSharingMode: 'share-latest'` so queued callers get the same result without re-issuing.
|
||||
- **Global concurrency cap** (`acme-global-concurrency`): Limits total parallel ACME operations (default 5, configurable via `maxConcurrentIssuances`).
|
||||
- **Account-level rate limiting** (`acme-account-rate-limit`): Sliding-window rate limit (default 250 orders per 3 hours, configurable via `maxOrdersPerWindow`/`orderWindowMs`) to stay under Let's Encrypt limits.
|
||||
- **Step-based progress**: The cert issuance task uses `notifyStep()` for prepare/authorize/finalize/store phases, observable via `smartAcme.certIssuanceEvents`.
|
||||
|
||||
Key implementation details:
|
||||
- A single reusable `Task` named `cert-issuance` handles all domains via `triggerTaskConstrained()` with different inputs.
|
||||
- The `shouldExecute` callback on the domain mutex checks the certmanager cache as a safety net.
|
||||
- `TaskManager.start()` is called in `SmartAcme.start()` and `TaskManager.stop()` in `SmartAcme.stop()`.
|
||||
- The "no cronjobs specified" log messages during tests come from taskbuffer's internal CronManager polling — harmless noise when no cron tasks are scheduled.
|
||||
|
||||
## ACME Directory Server (ts_server/)
|
||||
|
||||
As of v9.2.0, a built-in ACME Directory Server lives under `ts_server/`. This is a full RFC 8555-compliant CA server that allows running your own Certificate Authority.
|
||||
|
||||
Key files:
|
||||
- `ts_server/server.classes.acmeserver.ts` — Top-level `AcmeServer` facade (start/stop/config)
|
||||
- `ts_server/server.classes.ca.ts` — Self-signed root CA generation + certificate signing via `@peculiar/x509`
|
||||
- `ts_server/server.classes.jws.verifier.ts` — JWS signature verification (inverse of `AcmeCrypto.createJws`)
|
||||
- `ts_server/server.classes.router.ts` — Minimal HTTP router with `:param` support using raw `node:http`
|
||||
- `ts_server/server.classes.nonce.ts` — Single-use replay nonce management
|
||||
- `ts_server/server.classes.challenge.verifier.ts` — HTTP-01/DNS-01 verification (with bypass mode)
|
||||
- `ts_server/server.classes.account.store.ts` — In-memory account storage
|
||||
- `ts_server/server.classes.order.store.ts` — In-memory order/authz/challenge/cert storage
|
||||
- `ts_server/server.handlers.*.ts` — Route handlers for each ACME endpoint
|
||||
|
||||
Design decisions:
|
||||
- Uses raw `node:http` (no framework dependency — `@api.global/typedserver` was explicitly removed in v8.1.0)
|
||||
- Zero new dependencies: uses `node:crypto`, `@peculiar/x509`, and existing project deps
|
||||
- Reuses `AcmeCrypto` for JWK thumbprint/base64url, ACME interfaces for response types, `AcmeError` patterns
|
||||
- `AcmeCrypto.getAlg()` was made public (was private) for use by the JWS verifier
|
||||
- Storage interfaces (`IServerAccountStore`, `IServerOrderStore`) are pluggable, with in-memory defaults
|
||||
- `challengeVerification: false` option auto-approves challenges for testing
|
||||
- `tsbuild tsfolders` automatically compiles `ts_server/` to `dist_ts_server/`
|
||||
|
||||
## Dependency Notes
|
||||
|
||||
- `acme-client` was replaced with custom implementation in `ts/acme/` + `@peculiar/x509` for CSR generation
|
||||
- `@push.rocks/smartfile`, `@api.global/typedserver`, `@push.rocks/smartrequest`, `@push.rocks/smartpromise` were removed as unused dependencies in v8.1.0
|
||||
- The `@apiclient.xyz/cloudflare` `convenience` namespace is deprecated but still functional. The `Dns01Handler` accepts an `IConvenientDnsProvider` interface which remains stable.
|
||||
- Test imports use `@git.zone/tstest/tapbundle` (not `@push.rocks/tapbundle`)
|
||||
- Build uses `tsbuild` (no flags needed, v4+)
|
||||
- Build uses `tsbuild tsfolders` (v4.3.0+) — auto-discovers and compiles `ts/` and `ts_server/` directories
|
||||
|
||||
315
readme.md
315
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartacme
|
||||
|
||||
A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power. 🔒
|
||||
A TypeScript-based ACME client and server for certificate management with a focus on simplicity and power. Includes a full RFC 8555-compliant ACME client for Let's Encrypt and a built-in ACME Directory Server for running your own Certificate Authority.
|
||||
|
||||
## Issue Reporting and Security
|
||||
|
||||
@@ -16,9 +16,9 @@ Ensure your project uses TypeScript and ECMAScript Modules (ESM).
|
||||
|
||||
## Usage
|
||||
|
||||
`@push.rocks/smartacme` automates the full ACME certificate lifecycle — obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It supports pluggable challenge handlers (DNS-01, HTTP-01) and pluggable certificate storage backends (MongoDB, in-memory, or your own).
|
||||
`@push.rocks/smartacme` automates the full ACME certificate lifecycle — obtaining, renewing, and storing SSL/TLS certificates from Let's Encrypt. It features a built-in RFC 8555-compliant ACME protocol implementation, pluggable challenge handlers (DNS-01, HTTP-01), pluggable certificate storage backends (MongoDB, in-memory, or your own), structured error handling with smart retry logic, and built-in concurrency control with rate limiting to keep you safely within Let's Encrypt limits.
|
||||
|
||||
### Quick Start
|
||||
### 🚀 Quick Start
|
||||
|
||||
```typescript
|
||||
import { SmartAcme, certmanagers, handlers } from '@push.rocks/smartacme';
|
||||
@@ -47,39 +47,44 @@ await smartAcme.start();
|
||||
|
||||
// 4. Get a certificate
|
||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
||||
console.log(cert.publicKey); // PEM certificate
|
||||
console.log(cert.publicKey); // PEM certificate chain
|
||||
console.log(cert.privateKey); // PEM private key
|
||||
|
||||
// 5. Clean up
|
||||
await smartAcme.stop();
|
||||
```
|
||||
|
||||
### SmartAcme Options
|
||||
### ⚙️ SmartAcme Options
|
||||
|
||||
```typescript
|
||||
interface ISmartAcmeOptions {
|
||||
accountEmail: string; // ACME account email
|
||||
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
|
||||
certManager: ICertManager; // Certificate storage backend
|
||||
environment: 'production' | 'integration'; // LetsEncrypt environment
|
||||
challengeHandlers: IChallengeHandler[]; // At least one handler required
|
||||
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
|
||||
retryOptions?: { // Optional retry/backoff config
|
||||
retries?: number;
|
||||
factor?: number;
|
||||
minTimeoutMs?: number;
|
||||
maxTimeoutMs?: number;
|
||||
accountEmail: string; // ACME account email
|
||||
accountPrivateKey?: string; // Optional account key (auto-generated if omitted)
|
||||
certManager: ICertManager; // Certificate storage backend
|
||||
environment: 'production' | 'integration'; // Let's Encrypt environment
|
||||
challengeHandlers: IChallengeHandler[]; // At least one handler required
|
||||
challengePriority?: string[]; // e.g. ['dns-01', 'http-01']
|
||||
retryOptions?: { // Optional retry/backoff config
|
||||
retries?: number; // Default: 10
|
||||
factor?: number; // Default: 4
|
||||
minTimeoutMs?: number; // Default: 1000
|
||||
maxTimeoutMs?: number; // Default: 60000
|
||||
};
|
||||
// Concurrency & rate limiting
|
||||
maxConcurrentIssuances?: number; // Global cap on parallel ACME ops (default: 5)
|
||||
maxOrdersPerWindow?: number; // Max orders in sliding window (default: 250)
|
||||
orderWindowMs?: number; // Sliding window duration in ms (default: 3 hours)
|
||||
}
|
||||
```
|
||||
|
||||
### Getting Certificates
|
||||
### 📜 Getting Certificates
|
||||
|
||||
```typescript
|
||||
// Standard certificate for a single domain
|
||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
||||
|
||||
// Include wildcard certificate (requires DNS-01 handler)
|
||||
// Include wildcard coverage (requires DNS-01 handler)
|
||||
// Issues a single cert covering example.com AND *.example.com
|
||||
const certWithWildcard = await smartAcme.getCertificateForDomain('example.com', {
|
||||
includeWildcard: true,
|
||||
});
|
||||
@@ -90,25 +95,98 @@ const wildcardCert = await smartAcme.getCertificateForDomain('*.example.com');
|
||||
|
||||
Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration.
|
||||
|
||||
### Certificate Object
|
||||
### 📦 Certificate Object
|
||||
|
||||
The returned `SmartacmeCert` object has these properties:
|
||||
The returned `SmartacmeCert` (also exported as `Cert`) object has these properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
|-------------|----------|--------------------------------------|
|
||||
| `id` | `string` | Unique certificate identifier |
|
||||
| `domainName`| `string` | Domain the cert is issued for |
|
||||
| `publicKey` | `string` | PEM-encoded certificate |
|
||||
| `publicKey` | `string` | PEM-encoded certificate chain |
|
||||
| `privateKey`| `string` | PEM-encoded private key |
|
||||
| `csr` | `string` | Certificate Signing Request |
|
||||
| `created` | `number` | Timestamp of creation |
|
||||
| `validUntil`| `number` | Timestamp of expiration |
|
||||
|
||||
Useful methods:
|
||||
|
||||
```typescript
|
||||
cert.isStillValid(); // true if not expired
|
||||
cert.shouldBeRenewed(); // true if expires within 10 days
|
||||
```
|
||||
|
||||
## 🔀 Concurrency Control & Rate Limiting
|
||||
|
||||
When many callers request certificates concurrently (e.g., hundreds of subdomains under the same TLD), SmartAcme automatically handles deduplication, concurrency, and rate limiting using a built-in task manager powered by `@push.rocks/taskbuffer`.
|
||||
|
||||
### How It Works
|
||||
|
||||
Three constraint layers protect your ACME account:
|
||||
|
||||
| Layer | What It Does | Default |
|
||||
|-------|-------------|---------|
|
||||
| **Per-domain mutex** | Only one issuance runs per base domain at a time. Concurrent requests for the same domain automatically wait and receive the same certificate result. | 1 concurrent per domain |
|
||||
| **Global concurrency cap** | Limits total parallel ACME operations across all domains. | 5 concurrent |
|
||||
| **Account rate limit** | Sliding-window rate limiter that keeps you under Let's Encrypt's 300 orders/3h account limit. | 250 per 3 hours |
|
||||
|
||||
### 🛡️ Automatic Request Deduplication
|
||||
|
||||
If 100 requests come in for subdomains of `example.com` simultaneously, only **one** ACME issuance runs. All other callers automatically wait and receive the same certificate — no duplicate orders, no wasted rate limit budget.
|
||||
|
||||
```typescript
|
||||
// These all resolve to the same certificate with a single ACME order:
|
||||
const results = await Promise.all([
|
||||
smartAcme.getCertificateForDomain('app.example.com'),
|
||||
smartAcme.getCertificateForDomain('api.example.com'),
|
||||
smartAcme.getCertificateForDomain('cdn.example.com'),
|
||||
]);
|
||||
```
|
||||
|
||||
### ⚡ Configuring Limits
|
||||
|
||||
```typescript
|
||||
const smartAcme = new SmartAcme({
|
||||
accountEmail: 'admin@example.com',
|
||||
certManager,
|
||||
environment: 'production',
|
||||
challengeHandlers: [dnsHandler],
|
||||
maxConcurrentIssuances: 10, // Allow up to 10 parallel ACME issuances
|
||||
maxOrdersPerWindow: 200, // Cap at 200 orders per window
|
||||
orderWindowMs: 2 * 60 * 60_000, // 2-hour sliding window
|
||||
});
|
||||
```
|
||||
|
||||
### 📊 Observing Issuance Progress
|
||||
|
||||
Subscribe to the `certIssuanceEvents` stream to observe certificate issuance progress in real-time:
|
||||
|
||||
```typescript
|
||||
smartAcme.certIssuanceEvents.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case 'started':
|
||||
console.log(`🔄 Issuance started: ${event.task.name}`);
|
||||
break;
|
||||
case 'step':
|
||||
console.log(`📍 Step: ${event.stepName} (${event.task.currentProgress}%)`);
|
||||
break;
|
||||
case 'completed':
|
||||
console.log(`✅ Issuance completed: ${event.task.name}`);
|
||||
break;
|
||||
case 'failed':
|
||||
console.log(`❌ Issuance failed: ${event.error}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Each issuance goes through four steps: **prepare** (10%) → **authorize** (40%) → **finalize** (30%) → **store** (20%).
|
||||
|
||||
## Certificate Managers
|
||||
|
||||
SmartAcme uses the `ICertManager` interface for pluggable certificate storage.
|
||||
|
||||
### MongoCertManager
|
||||
### 🗄️ MongoCertManager
|
||||
|
||||
Persistent storage backed by MongoDB using `@push.rocks/smartdata`:
|
||||
|
||||
@@ -122,7 +200,7 @@ const certManager = new certmanagers.MongoCertManager({
|
||||
});
|
||||
```
|
||||
|
||||
### MemoryCertManager
|
||||
### 🧪 MemoryCertManager
|
||||
|
||||
In-memory storage, ideal for testing or ephemeral workloads:
|
||||
|
||||
@@ -132,13 +210,12 @@ import { certmanagers } from '@push.rocks/smartacme';
|
||||
const certManager = new certmanagers.MemoryCertManager();
|
||||
```
|
||||
|
||||
### Custom Certificate Manager
|
||||
### 🔧 Custom Certificate Manager
|
||||
|
||||
Implement the `ICertManager` interface for your own storage backend:
|
||||
|
||||
```typescript
|
||||
import type { ICertManager } from '@push.rocks/smartacme';
|
||||
import { Cert } from '@push.rocks/smartacme';
|
||||
import type { ICertManager, Cert } from '@push.rocks/smartacme';
|
||||
|
||||
class RedisCertManager implements ICertManager {
|
||||
async init(): Promise<void> { /* connect */ }
|
||||
@@ -166,7 +243,7 @@ const cfAccount = new cloudflare.CloudflareAccount('YOUR_CF_TOKEN');
|
||||
const dnsHandler = new handlers.Dns01Handler(cfAccount);
|
||||
```
|
||||
|
||||
DNS-01 is required for wildcard certificates and works regardless of server accessibility.
|
||||
DNS-01 is **required** for wildcard certificates and works regardless of server accessibility.
|
||||
|
||||
### 📁 Http01Webroot
|
||||
|
||||
@@ -197,12 +274,12 @@ app.use((req, res, next) => memHandler.handleRequest(req, res, next));
|
||||
|
||||
Perfect for serverless or container environments where filesystem access is limited.
|
||||
|
||||
### Custom Challenge Handler
|
||||
### 🔧 Custom Challenge Handler
|
||||
|
||||
Implement `IChallengeHandler<T>` for custom challenge types:
|
||||
|
||||
```typescript
|
||||
import type { IChallengeHandler } from '@push.rocks/smartacme';
|
||||
import type { handlers } from '@push.rocks/smartacme';
|
||||
|
||||
interface MyChallenge {
|
||||
type: string;
|
||||
@@ -210,28 +287,54 @@ interface MyChallenge {
|
||||
keyAuthorization: string;
|
||||
}
|
||||
|
||||
class MyHandler implements IChallengeHandler<MyChallenge> {
|
||||
class MyHandler implements handlers.IChallengeHandler<MyChallenge> {
|
||||
getSupportedTypes(): string[] { return ['http-01']; }
|
||||
async prepare(ch: MyChallenge): Promise<void> { /* ... */ }
|
||||
async cleanup(ch: MyChallenge): Promise<void> { /* ... */ }
|
||||
async prepare(ch: MyChallenge): Promise<void> { /* set up challenge response */ }
|
||||
async cleanup(ch: MyChallenge): Promise<void> { /* tear down */ }
|
||||
async checkWetherDomainIsSupported(domain: string): Promise<boolean> { return true; }
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
SmartAcme provides structured ACME error handling via the `AcmeError` class, which carries full RFC 8555 error information:
|
||||
|
||||
```typescript
|
||||
import { AcmeError } from '@push.rocks/smartacme/ts/acme/acme.classes.error.js';
|
||||
|
||||
try {
|
||||
const cert = await smartAcme.getCertificateForDomain('example.com');
|
||||
} catch (err) {
|
||||
if (err instanceof AcmeError) {
|
||||
console.log(err.status); // HTTP status code (e.g. 429)
|
||||
console.log(err.type); // ACME error URN (e.g. 'urn:ietf:params:acme:error:rateLimited')
|
||||
console.log(err.detail); // Human-readable message
|
||||
console.log(err.subproblems); // Per-identifier sub-errors (RFC 8555 §6.7.1)
|
||||
console.log(err.retryAfter); // Retry-After value in seconds
|
||||
console.log(err.isRateLimited); // true for 429 or rateLimited type
|
||||
console.log(err.isRetryable); // true for 429, 503, 5xx, badNonce; false for 403/404/409
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The built-in retry logic is **error-aware**: non-retryable errors (403, 404, 409) are thrown immediately without wasting retry attempts, and rate-limited responses respect the server's `Retry-After` header instead of using blind exponential backoff.
|
||||
|
||||
## Domain Matching
|
||||
|
||||
SmartAcme automatically maps subdomains to their base domain for certificate lookups:
|
||||
|
||||
```typescript
|
||||
// subdomain.example.com → certificate for example.com
|
||||
// *.example.com → certificate for example.com
|
||||
// a.b.example.com → not supported (4+ level domains)
|
||||
```
|
||||
subdomain.example.com → certificate for example.com ✅
|
||||
*.example.com → certificate for example.com ✅
|
||||
a.b.example.com → not supported (4+ levels) ❌
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- **`production`** — Uses LetsEncrypt production servers. Rate limits apply.
|
||||
- **`integration`** — Uses LetsEncrypt staging servers. No rate limits, but certificates are not trusted by browsers. Use for testing.
|
||||
| Environment | Description |
|
||||
|----------------|-------------|
|
||||
| `production` | Let's Encrypt production servers. Certificates are browser-trusted. [Rate limits](https://letsencrypt.org/docs/rate-limits/) apply. |
|
||||
| `integration` | Let's Encrypt staging servers. No rate limits, but certificates are **not** browser-trusted. Use for testing. |
|
||||
|
||||
## Complete Example with HTTP-01
|
||||
|
||||
@@ -269,13 +372,141 @@ await smartAcme.stop();
|
||||
server.close();
|
||||
```
|
||||
|
||||
## Testing
|
||||
## ACME Directory Server (Built-in CA)
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
SmartAcme includes a full RFC 8555-compliant ACME Directory Server, allowing you to run your own Certificate Authority. This is useful for internal PKI, development/testing environments, and air-gapped networks.
|
||||
|
||||
### Quick Start — ACME Server
|
||||
|
||||
```typescript
|
||||
import { server } from '@push.rocks/smartacme';
|
||||
|
||||
const acmeServer = new server.AcmeServer({
|
||||
port: 14000,
|
||||
challengeVerification: false, // Auto-approve challenges (for testing)
|
||||
caOptions: {
|
||||
commonName: 'My Internal CA',
|
||||
certValidityDays: 365,
|
||||
},
|
||||
});
|
||||
|
||||
await acmeServer.start();
|
||||
console.log(acmeServer.getDirectoryUrl()); // http://localhost:14000/directory
|
||||
console.log(acmeServer.getCaCertPem()); // Root CA certificate in PEM format
|
||||
|
||||
// ... use it, then shut down
|
||||
await acmeServer.stop();
|
||||
```
|
||||
|
||||
Tests use `@git.zone/tstest` with the tapbundle assertion library.
|
||||
### Server Options
|
||||
|
||||
```typescript
|
||||
interface IAcmeServerOptions {
|
||||
port?: number; // Default: 14000
|
||||
hostname?: string; // Default: '0.0.0.0'
|
||||
baseUrl?: string; // Auto-built from hostname:port if not provided
|
||||
challengeVerification?: boolean; // Default: true. Set false to auto-approve challenges
|
||||
caOptions?: {
|
||||
commonName?: string; // CA subject CN (default: 'SmartACME Test CA')
|
||||
validityDays?: number; // Root cert validity in days (default: 3650)
|
||||
certValidityDays?: number; // Issued cert validity in days (default: 90)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Server with SmartAcme Client
|
||||
|
||||
Point the SmartAcme client at your own ACME server for a fully self-contained PKI:
|
||||
|
||||
```typescript
|
||||
import { SmartAcme, certmanagers, handlers, server } from '@push.rocks/smartacme';
|
||||
|
||||
// 1. Start your own CA
|
||||
const acmeServer = new server.AcmeServer({
|
||||
port: 14000,
|
||||
challengeVerification: false,
|
||||
});
|
||||
await acmeServer.start();
|
||||
|
||||
// 2. Set up the client pointing at your CA
|
||||
const memHandler = new handlers.Http01MemoryHandler();
|
||||
const smartAcme = new SmartAcme({
|
||||
accountEmail: 'admin@internal.example.com',
|
||||
certManager: new certmanagers.MemoryCertManager(),
|
||||
environment: 'integration',
|
||||
challengeHandlers: [memHandler],
|
||||
directoryUrl: acmeServer.getDirectoryUrl(), // Use your own CA!
|
||||
});
|
||||
|
||||
await smartAcme.start();
|
||||
const cert = await smartAcme.getCertificateForDomain('myapp.internal');
|
||||
// cert.publicKey — PEM certificate chain signed by your CA
|
||||
// cert.privateKey — PEM private key
|
||||
|
||||
await smartAcme.stop();
|
||||
await acmeServer.stop();
|
||||
```
|
||||
|
||||
### Server Endpoints
|
||||
|
||||
The ACME server implements all RFC 8555 endpoints:
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/directory` | GET | ACME directory with all endpoint URLs |
|
||||
| `/new-nonce` | HEAD/GET | Fresh replay nonce |
|
||||
| `/new-account` | POST | Account registration/lookup |
|
||||
| `/new-order` | POST | Create certificate order |
|
||||
| `/order/:id` | POST | Poll order status |
|
||||
| `/authz/:id` | POST | Get authorization with challenges |
|
||||
| `/challenge/:id` | POST | Trigger or poll challenge validation |
|
||||
| `/finalize/:id` | POST | Submit CSR and issue certificate |
|
||||
| `/cert/:id` | POST | Download PEM certificate chain |
|
||||
|
||||
### Challenge Verification
|
||||
|
||||
By default, the server performs real challenge verification (HTTP-01 fetches the token, DNS-01 queries TXT records). Set `challengeVerification: false` to auto-approve all challenges — useful for testing or internal environments where domain validation isn't needed.
|
||||
|
||||
### Root CA Certificate
|
||||
|
||||
Use `getCaCertPem()` to retrieve the root CA certificate for trust configuration:
|
||||
|
||||
```typescript
|
||||
import * as fs from 'fs';
|
||||
fs.writeFileSync('/usr/local/share/ca-certificates/my-ca.crt', acmeServer.getCaCertPem());
|
||||
// Then: sudo update-ca-certificates
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
|
||||
|
||||
### Client Modules (`ts/acme/`)
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `AcmeClient` | Top-level ACME facade — orders, authorizations, finalization |
|
||||
| `AcmeCrypto` | RSA key generation, JWK/JWS (RFC 7515/7638), CSR via `@peculiar/x509` |
|
||||
| `AcmeHttpClient` | JWS-signed HTTP transport with nonce management and structured logging |
|
||||
| `AcmeError` | Structured error class with type URN, subproblems, Retry-After, retryability |
|
||||
| `AcmeOrderManager` | Order lifecycle — create, poll, finalize, download certificate |
|
||||
| `AcmeChallengeManager` | Key authorization computation and challenge completion |
|
||||
| `TaskManager` | Constraint-based concurrency control, rate limiting, and request deduplication via `@push.rocks/taskbuffer` |
|
||||
|
||||
### Server Modules (`ts_server/`)
|
||||
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `AcmeServer` | Top-level server facade — start, stop, configuration |
|
||||
| `AcmeServerCA` | Self-signed root CA generation and certificate signing via `@peculiar/x509` |
|
||||
| `JwsVerifier` | JWS signature verification (inverse of `AcmeCrypto.createJws`) |
|
||||
| `NonceManager` | Single-use replay nonce generation and validation |
|
||||
| `ChallengeVerifier` | HTTP-01 and DNS-01 challenge verification (with bypass mode) |
|
||||
| `AcmeRouter` | Minimal HTTP router with parameterized path support |
|
||||
| `MemoryAccountStore` | In-memory ACME account storage |
|
||||
| `MemoryOrderStore` | In-memory order, authorization, challenge, and certificate storage |
|
||||
|
||||
All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation and certificate signing.
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
|
||||
336
test/test.acme-server.node+bun+deno.ts
Normal file
336
test/test.acme-server.node+bun+deno.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { AcmeServer } from '../ts_server/index.js';
|
||||
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
||||
|
||||
const TEST_PORT = 14567;
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
let server: AcmeServer;
|
||||
let accountKeyPem: string;
|
||||
let accountUrl: string;
|
||||
let nonce: string;
|
||||
|
||||
// Helper: simple HTTP request
|
||||
function httpRequest(
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; headers: Record<string, string>; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urlObj = new URL(url);
|
||||
const options: http.RequestOptions = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port,
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method,
|
||||
headers: {
|
||||
...headers,
|
||||
...(body ? { 'Content-Length': Buffer.byteLength(body).toString() } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
const responseHeaders: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(res.headers)) {
|
||||
if (typeof value === 'string') {
|
||||
responseHeaders[key.toLowerCase()] = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
responseHeaders[key.toLowerCase()] = value[0];
|
||||
}
|
||||
}
|
||||
let data: any;
|
||||
const ct = responseHeaders['content-type'] || '';
|
||||
if (ct.includes('json') || ct.includes('problem')) {
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
} else {
|
||||
data = raw;
|
||||
}
|
||||
resolve({ status: res.statusCode || 0, headers: responseHeaders, data });
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10000, () => req.destroy(new Error('Timeout')));
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: signed ACME request
|
||||
async function signedRequest(
|
||||
url: string,
|
||||
payload: any | null,
|
||||
options?: { useJwk?: boolean },
|
||||
): Promise<{ status: number; headers: Record<string, string>; data: any }> {
|
||||
const jwsOptions: { nonce: string; kid?: string; jwk?: Record<string, string> } = { nonce };
|
||||
if (options?.useJwk) {
|
||||
jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem);
|
||||
} else if (accountUrl) {
|
||||
jwsOptions.kid = accountUrl;
|
||||
} else {
|
||||
jwsOptions.jwk = AcmeCrypto.getJwk(accountKeyPem);
|
||||
}
|
||||
|
||||
const jws = AcmeCrypto.createJws(accountKeyPem, url, payload, jwsOptions);
|
||||
const body = JSON.stringify(jws);
|
||||
|
||||
const response = await httpRequest(url, 'POST', body, {
|
||||
'Content-Type': 'application/jose+json',
|
||||
});
|
||||
|
||||
// Save nonce from response
|
||||
if (response.headers['replay-nonce']) {
|
||||
nonce = response.headers['replay-nonce'];
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Helper: get a fresh nonce
|
||||
async function fetchNonce(): Promise<string> {
|
||||
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD');
|
||||
return res.headers['replay-nonce'];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
tap.test('server: start ACME server', async () => {
|
||||
server = new AcmeServer({
|
||||
port: TEST_PORT,
|
||||
baseUrl: BASE_URL,
|
||||
challengeVerification: false, // Auto-approve challenges for testing
|
||||
caOptions: {
|
||||
commonName: 'Test ACME CA',
|
||||
certValidityDays: 30,
|
||||
},
|
||||
});
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tap.test('server: GET /directory returns valid directory', async () => {
|
||||
const res = await httpRequest(`${BASE_URL}/directory`, 'GET');
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.data.newNonce).toEqual(`${BASE_URL}/new-nonce`);
|
||||
expect(res.data.newAccount).toEqual(`${BASE_URL}/new-account`);
|
||||
expect(res.data.newOrder).toEqual(`${BASE_URL}/new-order`);
|
||||
expect(res.data.meta).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('server: HEAD /new-nonce returns Replay-Nonce header', async () => {
|
||||
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'HEAD');
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers['replay-nonce']).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('server: GET /new-nonce returns 204 with Replay-Nonce header', async () => {
|
||||
const res = await httpRequest(`${BASE_URL}/new-nonce`, 'GET');
|
||||
expect(res.status).toEqual(204);
|
||||
expect(res.headers['replay-nonce']).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('server: POST /new-account registers an account', async () => {
|
||||
accountKeyPem = AcmeCrypto.createRsaPrivateKey();
|
||||
nonce = await fetchNonce();
|
||||
|
||||
const res = await signedRequest(`${BASE_URL}/new-account`, {
|
||||
termsOfServiceAgreed: true,
|
||||
contact: ['mailto:test@example.com'],
|
||||
}, { useJwk: true });
|
||||
|
||||
expect(res.status).toEqual(201);
|
||||
expect(res.headers['location']).toBeTruthy();
|
||||
expect(res.data.status).toEqual('valid');
|
||||
accountUrl = res.headers['location'];
|
||||
});
|
||||
|
||||
tap.test('server: POST /new-account returns existing account', async () => {
|
||||
const res = await signedRequest(`${BASE_URL}/new-account`, {
|
||||
termsOfServiceAgreed: true,
|
||||
contact: ['mailto:test@example.com'],
|
||||
}, { useJwk: true });
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.headers['location']).toEqual(accountUrl);
|
||||
});
|
||||
|
||||
tap.test('server: full certificate issuance flow', async () => {
|
||||
// 1. Create order
|
||||
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
|
||||
identifiers: [{ type: 'dns', value: 'example.com' }],
|
||||
});
|
||||
|
||||
expect(orderRes.status).toEqual(201);
|
||||
expect(orderRes.data.status).toEqual('pending');
|
||||
expect(orderRes.data.authorizations).toBeTypeOf('object');
|
||||
expect(orderRes.data.finalize).toBeTruthy();
|
||||
const orderUrl = orderRes.headers['location'];
|
||||
const authzUrl = orderRes.data.authorizations[0];
|
||||
const finalizeUrl = orderRes.data.finalize;
|
||||
|
||||
// 2. Get authorization
|
||||
const authzRes = await signedRequest(authzUrl, null);
|
||||
expect(authzRes.status).toEqual(200);
|
||||
expect(authzRes.data.identifier.value).toEqual('example.com');
|
||||
expect(authzRes.data.challenges.length).toBeGreaterThan(0);
|
||||
|
||||
// Pick a challenge (prefer dns-01 since it's always available)
|
||||
const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01') || authzRes.data.challenges[0];
|
||||
expect(challenge.status).toEqual('pending');
|
||||
|
||||
// 3. Trigger challenge (auto-approved since challengeVerification is false)
|
||||
const challengeRes = await signedRequest(challenge.url, {});
|
||||
expect(challengeRes.status).toEqual(200);
|
||||
expect(challengeRes.data.status).toEqual('valid');
|
||||
|
||||
// 4. Poll order — should now be ready
|
||||
const orderPollRes = await signedRequest(orderUrl, null);
|
||||
expect(orderPollRes.status).toEqual(200);
|
||||
expect(orderPollRes.data.status).toEqual('ready');
|
||||
|
||||
// 5. Create CSR and finalize
|
||||
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
|
||||
commonName: 'example.com',
|
||||
altNames: ['example.com'],
|
||||
});
|
||||
|
||||
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
||||
const csrB64url = csrDer.toString('base64url');
|
||||
|
||||
const finalizeRes = await signedRequest(finalizeUrl, { csr: csrB64url });
|
||||
expect(finalizeRes.status).toEqual(200);
|
||||
expect(finalizeRes.data.status).toEqual('valid');
|
||||
expect(finalizeRes.data.certificate).toBeTruthy();
|
||||
|
||||
// 6. Download certificate
|
||||
const certUrl = finalizeRes.data.certificate;
|
||||
const certRes = await signedRequest(certUrl, null);
|
||||
expect(certRes.status).toEqual(200);
|
||||
expect(certRes.headers['content-type']).toEqual('application/pem-certificate-chain');
|
||||
expect(certRes.data).toInclude('BEGIN CERTIFICATE');
|
||||
|
||||
// Verify it's a valid PEM chain (at least 2 certs: end-entity + CA)
|
||||
const certMatches = (certRes.data as string).match(/-----BEGIN CERTIFICATE-----/g);
|
||||
expect(certMatches!.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
tap.test('server: wildcard certificate issuance', async () => {
|
||||
// Create order with wildcard
|
||||
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
|
||||
identifiers: [
|
||||
{ type: 'dns', value: 'test.org' },
|
||||
{ type: 'dns', value: '*.test.org' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(orderRes.status).toEqual(201);
|
||||
expect(orderRes.data.authorizations.length).toEqual(2);
|
||||
const orderUrl = orderRes.headers['location'];
|
||||
|
||||
// Complete all challenges
|
||||
for (const authzUrl of orderRes.data.authorizations) {
|
||||
const authzRes = await signedRequest(authzUrl, null);
|
||||
// For wildcard, only dns-01 should be available
|
||||
const challenge = authzRes.data.challenges.find((c: any) => c.type === 'dns-01');
|
||||
expect(challenge).toBeTruthy();
|
||||
await signedRequest(challenge.url, {});
|
||||
}
|
||||
|
||||
// Poll order
|
||||
const orderPollRes = await signedRequest(orderUrl, null);
|
||||
expect(orderPollRes.data.status).toEqual('ready');
|
||||
|
||||
// Finalize
|
||||
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
|
||||
commonName: 'test.org',
|
||||
altNames: ['test.org', '*.test.org'],
|
||||
});
|
||||
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
||||
const finalizeRes = await signedRequest(orderPollRes.data.finalize, {
|
||||
csr: csrDer.toString('base64url'),
|
||||
});
|
||||
expect(finalizeRes.data.status).toEqual('valid');
|
||||
|
||||
// Download cert
|
||||
const certRes = await signedRequest(finalizeRes.data.certificate, null);
|
||||
expect(certRes.data).toInclude('BEGIN CERTIFICATE');
|
||||
});
|
||||
|
||||
tap.test('server: rejects invalid JWS signature', async () => {
|
||||
// Create JWS with one key but sign URL intended for a different key
|
||||
const otherKey = AcmeCrypto.createRsaPrivateKey();
|
||||
const otherNonce = await fetchNonce();
|
||||
|
||||
const jws = AcmeCrypto.createJws(otherKey, `${BASE_URL}/new-order`, { identifiers: [] }, {
|
||||
nonce: otherNonce,
|
||||
kid: accountUrl, // Use our account URL but sign with a different key
|
||||
});
|
||||
|
||||
const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), {
|
||||
'Content-Type': 'application/jose+json',
|
||||
});
|
||||
|
||||
// Save nonce for next request
|
||||
if (res.headers['replay-nonce']) {
|
||||
nonce = res.headers['replay-nonce'];
|
||||
}
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
tap.test('server: rejects expired/bad nonce', async () => {
|
||||
const jws = AcmeCrypto.createJws(
|
||||
accountKeyPem,
|
||||
`${BASE_URL}/new-order`,
|
||||
{ identifiers: [{ type: 'dns', value: 'example.com' }] },
|
||||
{ nonce: 'definitely-not-a-valid-nonce', kid: accountUrl },
|
||||
);
|
||||
|
||||
const res = await httpRequest(`${BASE_URL}/new-order`, 'POST', JSON.stringify(jws), {
|
||||
'Content-Type': 'application/jose+json',
|
||||
});
|
||||
|
||||
if (res.headers['replay-nonce']) {
|
||||
nonce = res.headers['replay-nonce'];
|
||||
}
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(res.data.type).toEqual('urn:ietf:params:acme:error:badNonce');
|
||||
});
|
||||
|
||||
tap.test('server: finalize rejects order not in ready state', async () => {
|
||||
// Create a new order but don't complete challenges
|
||||
const orderRes = await signedRequest(`${BASE_URL}/new-order`, {
|
||||
identifiers: [{ type: 'dns', value: 'pending.example.com' }],
|
||||
});
|
||||
|
||||
const [keyPem, csrPem] = await AcmeCrypto.createCsr({
|
||||
commonName: 'pending.example.com',
|
||||
});
|
||||
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
||||
|
||||
const finalizeRes = await signedRequest(orderRes.data.finalize, {
|
||||
csr: csrDer.toString('base64url'),
|
||||
});
|
||||
|
||||
expect(finalizeRes.status).toEqual(403);
|
||||
expect(finalizeRes.data.type).toEqual('urn:ietf:params:acme:error:orderNotReady');
|
||||
});
|
||||
|
||||
tap.test('server: getCaCertPem returns root CA certificate', async () => {
|
||||
const caPem = server.getCaCertPem();
|
||||
expect(caPem).toInclude('BEGIN CERTIFICATE');
|
||||
});
|
||||
|
||||
tap.test('server: stop ACME server', async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -25,16 +25,12 @@ tap.test('HTTP-01 only configuration should work for regular domains', async ()
|
||||
smartAcmeInstance.certmatcher = {
|
||||
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
|
||||
} as any;
|
||||
smartAcmeInstance.interestMap = {
|
||||
checkInterest: async () => false,
|
||||
addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any)
|
||||
} as any;
|
||||
await smartAcmeInstance.certmanager.init();
|
||||
};
|
||||
await smartAcmeInstance.start();
|
||||
|
||||
|
||||
// Stub the core certificate methods to avoid actual ACME calls
|
||||
smartAcmeInstance.client = {
|
||||
(smartAcmeInstance as any).client = {
|
||||
createOrder: async (orderPayload: any) => {
|
||||
// Verify no wildcard is included in default request
|
||||
const identifiers = orderPayload.identifiers;
|
||||
@@ -47,8 +43,8 @@ tap.test('HTTP-01 only configuration should work for regular domains', async ()
|
||||
finalizeOrder: async () => {},
|
||||
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
|
||||
} as any;
|
||||
|
||||
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
|
||||
|
||||
(smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
|
||||
|
||||
// Mock certmanager methods
|
||||
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
|
||||
@@ -83,16 +79,12 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a
|
||||
smartAcmeInstance.certmatcher = {
|
||||
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
|
||||
} as any;
|
||||
smartAcmeInstance.interestMap = {
|
||||
checkInterest: async () => false,
|
||||
addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any)
|
||||
} as any;
|
||||
await smartAcmeInstance.certmanager.init();
|
||||
};
|
||||
await smartAcmeInstance.start();
|
||||
|
||||
|
||||
// Stub the core certificate methods
|
||||
smartAcmeInstance.client = {
|
||||
(smartAcmeInstance as any).client = {
|
||||
createOrder: async (orderPayload: any) => {
|
||||
const identifiers = orderPayload.identifiers;
|
||||
expect(identifiers.length).toEqual(2);
|
||||
@@ -104,8 +96,8 @@ tap.test('should only include wildcard when explicitly requested with DNS-01', a
|
||||
finalizeOrder: async () => {},
|
||||
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
|
||||
} as any;
|
||||
|
||||
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
|
||||
|
||||
(smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
|
||||
|
||||
// Mock certmanager methods
|
||||
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
|
||||
@@ -136,14 +128,10 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
|
||||
smartAcmeInstance.certmatcher = {
|
||||
getCertificateDomainNameByDomainName: (domain: string) => domain.replace('*.', '')
|
||||
} as any;
|
||||
smartAcmeInstance.interestMap = {
|
||||
checkInterest: async () => false,
|
||||
addInterest: async () => ({ interestFullfilled: new Promise(() => {}), fullfillInterest: () => {}, destroy: () => {} } as any)
|
||||
} as any;
|
||||
await smartAcmeInstance.certmanager.init();
|
||||
};
|
||||
await smartAcmeInstance.start();
|
||||
|
||||
|
||||
// Mock logger to capture warning
|
||||
const logSpy = { called: false, message: '' };
|
||||
smartAcmeInstance.logger.log = async (level: string, message: string) => {
|
||||
@@ -152,9 +140,9 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
|
||||
logSpy.message = message;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Stub the core certificate methods
|
||||
smartAcmeInstance.client = {
|
||||
(smartAcmeInstance as any).client = {
|
||||
createOrder: async (orderPayload: any) => {
|
||||
const identifiers = orderPayload.identifiers;
|
||||
// Should only have regular domain, no wildcard
|
||||
@@ -166,8 +154,8 @@ tap.test('should skip wildcard if requested but no DNS-01 handler available', as
|
||||
finalizeOrder: async () => {},
|
||||
getCertificate: async () => '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----',
|
||||
} as any;
|
||||
|
||||
smartAcmeInstance.retry = async (fn: () => Promise<any>) => fn();
|
||||
|
||||
(smartAcmeInstance as any).retry = async (fn: () => Promise<any>) => fn();
|
||||
|
||||
// Mock certmanager methods
|
||||
smartAcmeInstance.certmanager.retrieveCertificate = async () => null;
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartacme',
|
||||
version: '9.0.0',
|
||||
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
|
||||
version: '9.3.0',
|
||||
description: 'A TypeScript-based ACME client and server for certificate management with built-in CA, supporting LetsEncrypt and custom ACME authorities.'
|
||||
}
|
||||
|
||||
@@ -96,4 +96,11 @@ export class AcmeClient {
|
||||
async getCertificate(order: IAcmeOrder): Promise<string> {
|
||||
return this.orderManager.getCertificate(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy HTTP transport to release sockets and allow process exit.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.httpClient.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export class AcmeCrypto {
|
||||
/**
|
||||
* Determine JWS algorithm from key type
|
||||
*/
|
||||
private static getAlg(keyPem: string): string {
|
||||
static getAlg(keyPem: string): string {
|
||||
const keyObj = crypto.createPrivateKey(keyPem);
|
||||
const keyType = keyObj.asymmetricKeyType;
|
||||
if (keyType === 'rsa') return 'RS256';
|
||||
|
||||
@@ -17,11 +17,23 @@ export class AcmeHttpClient {
|
||||
private nonce: string | null = null;
|
||||
public kid: string | null = null;
|
||||
private logger?: TAcmeLogger;
|
||||
private httpsAgent: https.Agent;
|
||||
private httpAgent: http.Agent;
|
||||
|
||||
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
||||
this.directoryUrl = directoryUrl;
|
||||
this.accountKeyPem = accountKeyPem;
|
||||
this.logger = logger;
|
||||
this.httpsAgent = new https.Agent({ keepAlive: false });
|
||||
this.httpAgent = new http.Agent({ keepAlive: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy HTTP agents to release sockets and allow process exit.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.httpsAgent.destroy();
|
||||
this.httpAgent.destroy();
|
||||
}
|
||||
|
||||
private log(level: string, message: string, data?: any): void {
|
||||
@@ -186,6 +198,7 @@ export class AcmeHttpClient {
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
||||
};
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
|
||||
@@ -9,3 +9,10 @@ export { certmanagers };
|
||||
// handlers
|
||||
import * as handlers from './handlers/index.js';
|
||||
export { handlers };
|
||||
|
||||
// server (ACME Directory Server / CA)
|
||||
import * as server from '../ts_server/index.js';
|
||||
export { server };
|
||||
|
||||
// re-export taskbuffer event types for consumers
|
||||
export type { ITaskEvent, ITaskMetadata } from '@push.rocks/taskbuffer';
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as smartnetwork from '@push.rocks/smartnetwork';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
import * as smartstring from '@push.rocks/smartstring';
|
||||
import * as smarttime from '@push.rocks/smarttime';
|
||||
import * as taskbuffer from '@push.rocks/taskbuffer';
|
||||
|
||||
export {
|
||||
lik,
|
||||
@@ -30,6 +31,7 @@ export {
|
||||
smartunique,
|
||||
smartstring,
|
||||
smarttime,
|
||||
taskbuffer,
|
||||
};
|
||||
|
||||
// @tsclass scope
|
||||
|
||||
@@ -4,6 +4,22 @@ import { SmartacmeCertMatcher } from './smartacme.classes.certmatcher.js';
|
||||
import { commitinfo } from './00_commitinfo_data.js';
|
||||
import { SmartacmeCert } from './smartacme.classes.cert.js';
|
||||
|
||||
// ── Types & constants for certificate issuance task ──────────────────────────
|
||||
|
||||
interface ICertIssuanceInput {
|
||||
certDomainName: string;
|
||||
domainArg: string;
|
||||
isWildcardRequest: boolean;
|
||||
includeWildcard: boolean;
|
||||
}
|
||||
|
||||
const CERT_ISSUANCE_STEPS = [
|
||||
{ name: 'prepare', description: 'Creating ACME order', percentage: 10 },
|
||||
{ name: 'authorize', description: 'Solving ACME challenges', percentage: 40 },
|
||||
{ name: 'finalize', description: 'Finalizing and getting cert', percentage: 30 },
|
||||
{ name: 'store', description: 'Storing certificate', percentage: 20 },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* the options for the class @see SmartAcme
|
||||
*/
|
||||
@@ -38,6 +54,21 @@ export interface ISmartAcmeOptions {
|
||||
* Defaults to ['dns-01'] or first supported type from handlers.
|
||||
*/
|
||||
challengePriority?: string[];
|
||||
/**
|
||||
* Maximum number of concurrent ACME issuances across all domains.
|
||||
* Defaults to 5.
|
||||
*/
|
||||
maxConcurrentIssuances?: number;
|
||||
/**
|
||||
* Maximum ACME orders allowed within the sliding window.
|
||||
* Defaults to 250 (conservative limit under Let's Encrypt's 300/3h).
|
||||
*/
|
||||
maxOrdersPerWindow?: number;
|
||||
/**
|
||||
* Sliding window duration in milliseconds for rate limiting.
|
||||
* Defaults to 3 hours (10_800_000 ms).
|
||||
*/
|
||||
orderWindowMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,12 +106,21 @@ export class SmartAcme {
|
||||
private pendingChallenges: plugins.tsclass.network.IDnsChallenge[] = [];
|
||||
// priority order of challenge types
|
||||
private challengePriority: string[];
|
||||
// Map for coordinating concurrent certificate requests
|
||||
private interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
|
||||
// TaskManager for coordinating concurrent certificate requests
|
||||
private taskManager: plugins.taskbuffer.TaskManager;
|
||||
// Single reusable task for certificate issuance
|
||||
private certIssuanceTask: plugins.taskbuffer.Task<undefined, typeof CERT_ISSUANCE_STEPS>;
|
||||
// bound signal handlers so they can be removed on stop()
|
||||
private boundSigintHandler: (() => void) | null = null;
|
||||
private boundSigtermHandler: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Exposes the aggregated task event stream for observing certificate issuance progress.
|
||||
*/
|
||||
public get certIssuanceEvents(): plugins.taskbuffer.TaskManager['taskSubject'] {
|
||||
return this.taskManager.taskSubject;
|
||||
}
|
||||
|
||||
constructor(optionsArg: ISmartAcmeOptions) {
|
||||
this.options = optionsArg;
|
||||
this.logger = plugins.smartlog.Smartlog.createForCommitinfo(commitinfo);
|
||||
@@ -105,8 +145,60 @@ export class SmartAcme {
|
||||
optionsArg.challengePriority && optionsArg.challengePriority.length > 0
|
||||
? optionsArg.challengePriority
|
||||
: this.challengeHandlers.map((h) => h.getSupportedTypes()[0]);
|
||||
// initialize interest coordination
|
||||
this.interestMap = new plugins.lik.InterestMap((domain) => domain);
|
||||
|
||||
// ── TaskManager setup ──────────────────────────────────────────────────
|
||||
this.taskManager = new plugins.taskbuffer.TaskManager();
|
||||
|
||||
// Constraint 1: Per-domain mutex — one issuance at a time per TLD, with result sharing
|
||||
const certDomainMutex = new plugins.taskbuffer.TaskConstraintGroup({
|
||||
name: 'cert-domain-mutex',
|
||||
maxConcurrent: 1,
|
||||
resultSharingMode: 'share-latest',
|
||||
constraintKeyForExecution: (_task, input?: ICertIssuanceInput) => {
|
||||
return input?.certDomainName ?? null;
|
||||
},
|
||||
shouldExecute: async (_task, input?: ICertIssuanceInput) => {
|
||||
if (!input?.certDomainName || !this.certmanager) return true;
|
||||
// Safety net: if a valid cert is already cached, skip re-issuance
|
||||
const existing = await this.certmanager.retrieveCertificate(input.certDomainName);
|
||||
if (existing && !existing.shouldBeRenewed()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Constraint 2: Global concurrency cap
|
||||
const acmeGlobalConcurrency = new plugins.taskbuffer.TaskConstraintGroup({
|
||||
name: 'acme-global-concurrency',
|
||||
maxConcurrent: optionsArg.maxConcurrentIssuances ?? 5,
|
||||
constraintKeyForExecution: () => 'global',
|
||||
});
|
||||
|
||||
// Constraint 3: Account-level rate limiting
|
||||
const acmeAccountRateLimit = new plugins.taskbuffer.TaskConstraintGroup({
|
||||
name: 'acme-account-rate-limit',
|
||||
rateLimit: {
|
||||
maxPerWindow: optionsArg.maxOrdersPerWindow ?? 250,
|
||||
windowMs: optionsArg.orderWindowMs ?? 10_800_000,
|
||||
},
|
||||
constraintKeyForExecution: () => 'account',
|
||||
});
|
||||
|
||||
this.taskManager.addConstraintGroup(certDomainMutex);
|
||||
this.taskManager.addConstraintGroup(acmeGlobalConcurrency);
|
||||
this.taskManager.addConstraintGroup(acmeAccountRateLimit);
|
||||
|
||||
// Create the single reusable certificate issuance task
|
||||
this.certIssuanceTask = new plugins.taskbuffer.Task({
|
||||
name: 'cert-issuance',
|
||||
steps: CERT_ISSUANCE_STEPS,
|
||||
taskFunction: async (input: ICertIssuanceInput) => {
|
||||
return this.performCertificateIssuance(input);
|
||||
},
|
||||
});
|
||||
|
||||
this.taskManager.addTask(this.certIssuanceTask);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +241,10 @@ export class SmartAcme {
|
||||
termsOfServiceAgreed: true,
|
||||
contact: [`mailto:${this.options.accountEmail}`],
|
||||
});
|
||||
|
||||
// Start the task manager
|
||||
await this.taskManager.start();
|
||||
|
||||
// Setup graceful shutdown handlers (store references for removal in stop())
|
||||
this.boundSigintHandler = () => this.handleSignal('SIGINT');
|
||||
this.boundSigtermHandler = () => this.handleSignal('SIGTERM');
|
||||
@@ -169,6 +265,16 @@ export class SmartAcme {
|
||||
process.removeListener('SIGTERM', this.boundSigtermHandler);
|
||||
this.boundSigtermHandler = null;
|
||||
}
|
||||
// Stop the task manager
|
||||
await this.taskManager.stop();
|
||||
// Destroy ACME HTTP transport (closes keep-alive sockets)
|
||||
if (this.client) {
|
||||
this.client.destroy();
|
||||
}
|
||||
// Destroy DNS client (kills Rust bridge child process if spawned)
|
||||
if (this.smartdns) {
|
||||
this.smartdns.destroy();
|
||||
}
|
||||
if (this.certmanager && typeof (this.certmanager as any).close === 'function') {
|
||||
await (this.certmanager as any).close();
|
||||
}
|
||||
@@ -247,8 +353,7 @@ export class SmartAcme {
|
||||
* * if not in the database announce it
|
||||
* * then get it from letsencrypt
|
||||
* * store it
|
||||
* * remove it from the pending map (which it go onto by announcing it)
|
||||
* * retrieve it from the databse and return it
|
||||
* * retrieve it from the database and return it
|
||||
*
|
||||
* @param domainArg
|
||||
* @param options Optional configuration for certificate generation
|
||||
@@ -276,35 +381,60 @@ export class SmartAcme {
|
||||
// Retrieve any existing certificate record by base domain.
|
||||
const retrievedCertificate = await this.certmanager.retrieveCertificate(certDomainName);
|
||||
|
||||
if (
|
||||
!retrievedCertificate &&
|
||||
(await this.interestMap.checkInterest(certDomainName))
|
||||
) {
|
||||
const existingCertificateInterest = this.interestMap.findInterest(certDomainName);
|
||||
const certificate = existingCertificateInterest.interestFullfilled;
|
||||
return certificate;
|
||||
} else if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
|
||||
if (retrievedCertificate && !retrievedCertificate.shouldBeRenewed()) {
|
||||
return retrievedCertificate;
|
||||
} else if (retrievedCertificate && retrievedCertificate.shouldBeRenewed()) {
|
||||
// Remove old certificate via certManager
|
||||
await this.certmanager.deleteCertificate(certDomainName);
|
||||
}
|
||||
|
||||
// lets make sure others get the same interest
|
||||
const currentDomainInterst = await this.interestMap.addInterest(certDomainName);
|
||||
// Build issuance input and trigger the constrained task
|
||||
const issuanceInput: ICertIssuanceInput = {
|
||||
certDomainName,
|
||||
domainArg,
|
||||
isWildcardRequest,
|
||||
includeWildcard: options?.includeWildcard ?? false,
|
||||
};
|
||||
|
||||
const result = await this.taskManager.triggerTaskConstrained(
|
||||
this.certIssuanceTask,
|
||||
issuanceInput,
|
||||
);
|
||||
|
||||
// If we got a cert directly (either from execution or result sharing), return it
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If shouldExecute returned false (cert appeared in cache), read from cache
|
||||
const cachedCert = await this.certmanager.retrieveCertificate(certDomainName);
|
||||
if (cachedCert) {
|
||||
return cachedCert;
|
||||
}
|
||||
|
||||
throw new Error(`Certificate issuance failed for ${certDomainName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the actual ACME certificate issuance flow.
|
||||
* Called by the certIssuanceTask's taskFunction.
|
||||
*/
|
||||
private async performCertificateIssuance(input: ICertIssuanceInput): Promise<SmartacmeCert> {
|
||||
const { certDomainName, isWildcardRequest, includeWildcard } = input;
|
||||
|
||||
// ── Step: prepare ─────────────────────────────────────────────────────
|
||||
this.certIssuanceTask.notifyStep('prepare');
|
||||
|
||||
// Build identifiers array based on request
|
||||
const identifiers = [];
|
||||
|
||||
const identifiers: Array<{ type: 'dns'; value: string }> = [];
|
||||
|
||||
if (isWildcardRequest) {
|
||||
// If requesting a wildcard directly, only add the wildcard
|
||||
identifiers.push({ type: 'dns', value: `*.${certDomainName}` });
|
||||
} else {
|
||||
// Add the regular domain
|
||||
identifiers.push({ type: 'dns', value: certDomainName });
|
||||
|
||||
// Only add wildcard if explicitly requested
|
||||
if (options?.includeWildcard) {
|
||||
} else {
|
||||
identifiers.push({ type: 'dns', value: certDomainName });
|
||||
|
||||
if (includeWildcard) {
|
||||
const hasDnsHandler = this.challengeHandlers.some((h) =>
|
||||
h.getSupportedTypes().includes('dns-01'),
|
||||
);
|
||||
@@ -321,6 +451,9 @@ export class SmartAcme {
|
||||
identifiers,
|
||||
}), 'createOrder');
|
||||
|
||||
// ── Step: authorize ───────────────────────────────────────────────────
|
||||
this.certIssuanceTask.notifyStep('authorize');
|
||||
|
||||
/* Get authorizations and select challenges */
|
||||
const authorizations = await this.retry(() => this.client.getAuthorizations(order), 'getAuthorizations');
|
||||
|
||||
@@ -344,45 +477,37 @@ export class SmartAcme {
|
||||
}
|
||||
const { type, handler } = selectedHandler;
|
||||
// build handler input with keyAuthorization
|
||||
let input: any;
|
||||
let challengeInput: any;
|
||||
// retrieve keyAuthorization for challenge
|
||||
const keyAuth = await this.client.getChallengeKeyAuthorization(selectedChallengeArg);
|
||||
if (type === 'dns-01') {
|
||||
input = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
|
||||
challengeInput = { type, hostName: `_acme-challenge.${authz.identifier.value}`, challenge: keyAuth };
|
||||
} else if (type === 'http-01') {
|
||||
// HTTP-01 requires serving token at webPath
|
||||
input = {
|
||||
challengeInput = {
|
||||
type,
|
||||
token: (selectedChallengeArg as any).token,
|
||||
keyAuthorization: keyAuth,
|
||||
webPath: `/.well-known/acme-challenge/${(selectedChallengeArg as any).token}`,
|
||||
};
|
||||
} else {
|
||||
// generic challenge input: include raw challenge properties
|
||||
input = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
|
||||
challengeInput = { type, keyAuthorization: keyAuth, ...selectedChallengeArg };
|
||||
}
|
||||
this.pendingChallenges.push(input);
|
||||
this.pendingChallenges.push(challengeInput);
|
||||
try {
|
||||
// Prepare the challenge (set DNS record, write file, etc.)
|
||||
await this.retry(() => handler.prepare(input), `${type}.prepare`);
|
||||
// For DNS-01, wait for propagation before verification
|
||||
await this.retry(() => handler.prepare(challengeInput), `${type}.prepare`);
|
||||
if (type === 'dns-01') {
|
||||
const dnsInput = input as { hostName: string; challenge: string };
|
||||
// Wait for authoritative DNS propagation before ACME verify
|
||||
const dnsInput = challengeInput as { hostName: string; challenge: string };
|
||||
await this.retry(
|
||||
() => this.smartdns.checkUntilAvailable(dnsInput.hostName, 'TXT', dnsInput.challenge, 100, 5000),
|
||||
`${type}.propagation`,
|
||||
);
|
||||
// Extra cool-down to ensure ACME server sees the new TXT record
|
||||
this.logger.log('info', 'Cooling down for 1 minute before ACME verification');
|
||||
await plugins.smartdelay.delayFor(60000);
|
||||
}
|
||||
// Notify ACME server to complete the challenge
|
||||
await this.retry(
|
||||
() => this.client.completeChallenge(selectedChallengeArg),
|
||||
`${type}.completeChallenge`,
|
||||
);
|
||||
// Wait for valid status (warnings on staging timeouts)
|
||||
try {
|
||||
await this.retry(
|
||||
() => this.client.waitForValidStatus(selectedChallengeArg),
|
||||
@@ -396,34 +521,32 @@ export class SmartAcme {
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Always cleanup resource
|
||||
try {
|
||||
await this.retry(() => handler.cleanup(input), `${type}.cleanup`);
|
||||
await this.retry(() => handler.cleanup(challengeInput), `${type}.cleanup`);
|
||||
} catch (err) {
|
||||
await this.logger.log('error', `Error during ${type}.cleanup`, err);
|
||||
} finally {
|
||||
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== input);
|
||||
this.pendingChallenges = this.pendingChallenges.filter((c) => c !== challengeInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Finalize order */
|
||||
const csrDomains = [];
|
||||
// ── Step: finalize ────────────────────────────────────────────────────
|
||||
this.certIssuanceTask.notifyStep('finalize');
|
||||
|
||||
const csrDomains: string[] = [];
|
||||
let commonName: string;
|
||||
|
||||
|
||||
if (isWildcardRequest) {
|
||||
// For wildcard requests, use wildcard as common name
|
||||
commonName = `*.${certDomainName}`;
|
||||
csrDomains.push(certDomainName); // Add base domain as alt name
|
||||
csrDomains.push(certDomainName);
|
||||
} else {
|
||||
// For regular requests, use base domain as common name
|
||||
commonName = certDomainName;
|
||||
if (options?.includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) {
|
||||
// If wildcard was successfully added, include it as alt name
|
||||
if (includeWildcard && identifiers.some(id => id.value === `*.${certDomainName}`)) {
|
||||
csrDomains.push(`*.${certDomainName}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const [key, csr] = await plugins.acme.AcmeCrypto.createCsr({
|
||||
commonName,
|
||||
altNames: csrDomains,
|
||||
@@ -432,9 +555,9 @@ export class SmartAcme {
|
||||
await this.retry(() => this.client.finalizeOrder(order, csr), 'finalizeOrder');
|
||||
const cert = await this.retry(() => this.client.getCertificate(order), 'getCertificate');
|
||||
|
||||
/* Done */
|
||||
// ── Step: store ───────────────────────────────────────────────────────
|
||||
this.certIssuanceTask.notifyStep('store');
|
||||
|
||||
// Store the new certificate record
|
||||
const certRecord = new SmartacmeCert({
|
||||
id: plugins.smartunique.shortId(),
|
||||
domainName: certDomainName,
|
||||
@@ -447,9 +570,7 @@ export class SmartAcme {
|
||||
await this.certmanager.storeCertificate(certRecord);
|
||||
|
||||
const newCertificate = await this.certmanager.retrieveCertificate(certDomainName);
|
||||
currentDomainInterst.fullfillInterest(newCertificate);
|
||||
currentDomainInterst.destroy();
|
||||
return newCertificate;
|
||||
return newCertificate ?? certRecord;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
12
ts_server/index.ts
Normal file
12
ts_server/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { AcmeServer } from './server.classes.acmeserver.js';
|
||||
export { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
export { AcmeServerCA } from './server.classes.ca.js';
|
||||
export type {
|
||||
IAcmeServerOptions,
|
||||
IServerAccountStore,
|
||||
IServerOrderStore,
|
||||
IServerAccount,
|
||||
IServerOrder,
|
||||
IServerAuthorization,
|
||||
IServerChallenge,
|
||||
} from './server.interfaces.js';
|
||||
27
ts_server/server.classes.account.store.ts
Normal file
27
ts_server/server.classes.account.store.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { IServerAccountStore, IServerAccount } from './server.interfaces.js';
|
||||
|
||||
/**
|
||||
* In-memory account storage for the ACME server.
|
||||
*/
|
||||
export class MemoryAccountStore implements IServerAccountStore {
|
||||
private accounts = new Map<string, IServerAccount>();
|
||||
private byThumbprint = new Map<string, string>();
|
||||
private byUrl = new Map<string, string>();
|
||||
|
||||
async create(account: IServerAccount): Promise<IServerAccount> {
|
||||
this.accounts.set(account.id, account);
|
||||
this.byThumbprint.set(account.thumbprint, account.id);
|
||||
this.byUrl.set(account.url, account.id);
|
||||
return account;
|
||||
}
|
||||
|
||||
async getByThumbprint(thumbprint: string): Promise<IServerAccount | null> {
|
||||
const id = this.byThumbprint.get(thumbprint);
|
||||
return id ? this.accounts.get(id) || null : null;
|
||||
}
|
||||
|
||||
async getByUrl(url: string): Promise<IServerAccount | null> {
|
||||
const id = this.byUrl.get(url);
|
||||
return id ? this.accounts.get(id) || null : null;
|
||||
}
|
||||
}
|
||||
128
ts_server/server.classes.acmeserver.ts
Normal file
128
ts_server/server.classes.acmeserver.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import * as http from 'node:http';
|
||||
import type { IAcmeServerOptions } from './server.interfaces.js';
|
||||
import { NonceManager } from './server.classes.nonce.js';
|
||||
import { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { MemoryAccountStore } from './server.classes.account.store.js';
|
||||
import { MemoryOrderStore } from './server.classes.order.store.js';
|
||||
import { AcmeServerCA } from './server.classes.ca.js';
|
||||
import { ChallengeVerifier } from './server.classes.challenge.verifier.js';
|
||||
import { AcmeRouter } from './server.classes.router.js';
|
||||
import { createDirectoryHandler } from './server.handlers.directory.js';
|
||||
import { createNonceHeadHandler, createNonceGetHandler } from './server.handlers.nonce.js';
|
||||
import { createAccountHandler } from './server.handlers.account.js';
|
||||
import { createNewOrderHandler, createOrderPollHandler } from './server.handlers.order.js';
|
||||
import { createAuthzHandler } from './server.handlers.authz.js';
|
||||
import { createChallengeHandler } from './server.handlers.challenge.js';
|
||||
import { createFinalizeHandler } from './server.handlers.finalize.js';
|
||||
import { createCertHandler } from './server.handlers.cert.js';
|
||||
|
||||
/**
|
||||
* ACME Directory Server — a self-contained RFC 8555 Certificate Authority.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const server = new AcmeServer({ port: 14000 });
|
||||
* await server.start();
|
||||
* console.log(server.getDirectoryUrl()); // http://localhost:14000/directory
|
||||
* ```
|
||||
*/
|
||||
export class AcmeServer {
|
||||
private options: Required<Pick<IAcmeServerOptions, 'port' | 'hostname'>> & IAcmeServerOptions;
|
||||
private httpServer: http.Server | null = null;
|
||||
private ca: AcmeServerCA;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(options: IAcmeServerOptions = {}) {
|
||||
this.options = {
|
||||
port: options.port ?? 14000,
|
||||
hostname: options.hostname ?? '0.0.0.0',
|
||||
...options,
|
||||
};
|
||||
this.baseUrl = options.baseUrl ?? `http://localhost:${this.options.port}`;
|
||||
this.ca = new AcmeServerCA(options.caOptions);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Initialize CA
|
||||
await this.ca.init();
|
||||
|
||||
// Create stores
|
||||
const accountStore = new MemoryAccountStore();
|
||||
const orderStore = new MemoryOrderStore();
|
||||
|
||||
// Create managers
|
||||
const nonceManager = new NonceManager();
|
||||
const jwsVerifier = new JwsVerifier(nonceManager, accountStore);
|
||||
const challengeVerifier = new ChallengeVerifier(this.options.challengeVerification ?? true);
|
||||
|
||||
// Create router and register routes
|
||||
const router = new AcmeRouter(nonceManager);
|
||||
|
||||
// Directory
|
||||
router.addRoute('GET', '/directory', createDirectoryHandler(this.baseUrl));
|
||||
|
||||
// Nonce
|
||||
router.addRoute('HEAD', '/new-nonce', createNonceHeadHandler());
|
||||
router.addRoute('GET', '/new-nonce', createNonceGetHandler());
|
||||
|
||||
// Account
|
||||
router.addRoute('POST', '/new-account', createAccountHandler(this.baseUrl, jwsVerifier, accountStore));
|
||||
|
||||
// Order
|
||||
router.addRoute('POST', '/new-order', createNewOrderHandler(this.baseUrl, jwsVerifier, orderStore));
|
||||
router.addRoute('POST', '/order/:id', createOrderPollHandler(this.baseUrl, jwsVerifier, orderStore));
|
||||
|
||||
// Authorization
|
||||
router.addRoute('POST', '/authz/:id', createAuthzHandler(this.baseUrl, jwsVerifier, orderStore));
|
||||
|
||||
// Challenge
|
||||
router.addRoute('POST', '/challenge/:id', createChallengeHandler(
|
||||
this.baseUrl,
|
||||
jwsVerifier,
|
||||
orderStore,
|
||||
accountStore,
|
||||
challengeVerifier,
|
||||
));
|
||||
|
||||
// Finalize
|
||||
router.addRoute('POST', '/finalize/:id', createFinalizeHandler(this.baseUrl, jwsVerifier, orderStore, this.ca));
|
||||
|
||||
// Certificate
|
||||
router.addRoute('POST', '/cert/:id', createCertHandler(this.baseUrl, jwsVerifier, orderStore));
|
||||
|
||||
// Start HTTP server
|
||||
this.httpServer = http.createServer((req, res) => {
|
||||
router.handle(req, res).catch((err) => {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
|
||||
res.end(JSON.stringify({
|
||||
type: 'urn:ietf:params:acme:error:serverInternal',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
status: 500,
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.httpServer!.listen(this.options.port, this.options.hostname, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.httpServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.httpServer!.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
this.httpServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
getDirectoryUrl(): string {
|
||||
return `${this.baseUrl}/directory`;
|
||||
}
|
||||
|
||||
getCaCertPem(): string {
|
||||
return this.ca.getCaCertPem();
|
||||
}
|
||||
}
|
||||
142
ts_server/server.classes.ca.ts
Normal file
142
ts_server/server.classes.ca.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Certificate Authority for the ACME server.
|
||||
* Generates a self-signed root CA and signs certificates from CSRs.
|
||||
* Uses @peculiar/x509 (already a project dependency).
|
||||
*/
|
||||
export class AcmeServerCA {
|
||||
private caKeyPair!: CryptoKeyPair;
|
||||
private caCert!: InstanceType<typeof import('@peculiar/x509').X509Certificate>;
|
||||
private caCertPem!: string;
|
||||
private certValidityDays: number;
|
||||
|
||||
constructor(private options: { commonName?: string; validityDays?: number; certValidityDays?: number } = {}) {
|
||||
this.certValidityDays = options.certValidityDays ?? 90;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const x509 = await import('@peculiar/x509');
|
||||
const { webcrypto } = crypto;
|
||||
x509.cryptoProvider.set(webcrypto as any);
|
||||
|
||||
// Generate RSA key pair for the CA
|
||||
this.caKeyPair = await webcrypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSASSA-PKCS1-v1_5',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
) as CryptoKeyPair;
|
||||
|
||||
const cn = this.options.commonName ?? 'SmartACME Test CA';
|
||||
const validityDays = this.options.validityDays ?? 3650;
|
||||
const notBefore = new Date();
|
||||
const notAfter = new Date();
|
||||
notAfter.setDate(notAfter.getDate() + validityDays);
|
||||
|
||||
// Create self-signed root CA certificate
|
||||
this.caCert = await x509.X509CertificateGenerator.createSelfSigned({
|
||||
serialNumber: this.randomSerialNumber(),
|
||||
name: `CN=${cn}`,
|
||||
notBefore,
|
||||
notAfter,
|
||||
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
|
||||
keys: this.caKeyPair,
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(true, undefined, true),
|
||||
new x509.KeyUsagesExtension(
|
||||
x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
|
||||
true,
|
||||
),
|
||||
await x509.SubjectKeyIdentifierExtension.create(this.caKeyPair.publicKey),
|
||||
],
|
||||
});
|
||||
|
||||
this.caCertPem = this.caCert.toString('pem');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a CSR and return a PEM certificate chain (end-entity + root CA).
|
||||
* @param csrDerBase64url - The CSR in base64url-encoded DER format (as sent by ACME clients)
|
||||
*/
|
||||
async signCsr(csrDerBase64url: string): Promise<string> {
|
||||
const x509 = await import('@peculiar/x509');
|
||||
const { webcrypto } = crypto;
|
||||
x509.cryptoProvider.set(webcrypto as any);
|
||||
|
||||
// Parse the CSR
|
||||
const csrDer = Buffer.from(csrDerBase64url, 'base64url');
|
||||
const csr = new x509.Pkcs10CertificateRequest(csrDer);
|
||||
|
||||
// Extract Subject Alternative Names from CSR extensions
|
||||
const sanNames: { type: 'dns'; value: string }[] = [];
|
||||
const sanExt = csr.extensions?.find(
|
||||
(ext) => ext.type === '2.5.29.17', // OID for SubjectAlternativeName
|
||||
);
|
||||
|
||||
if (sanExt) {
|
||||
const san = new x509.SubjectAlternativeNameExtension(sanExt.rawData);
|
||||
if (san.names) {
|
||||
const jsonNames = san.names.toJSON();
|
||||
for (const name of jsonNames) {
|
||||
if (name.type === 'dns') {
|
||||
sanNames.push({ type: 'dns', value: name.value });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no SAN found, use CN from subject
|
||||
if (sanNames.length === 0) {
|
||||
const cnMatch = csr.subject.match(/CN=([^,]+)/);
|
||||
if (cnMatch) {
|
||||
sanNames.push({ type: 'dns', value: cnMatch[1] });
|
||||
}
|
||||
}
|
||||
|
||||
const notBefore = new Date();
|
||||
const notAfter = new Date();
|
||||
notAfter.setDate(notAfter.getDate() + this.certValidityDays);
|
||||
|
||||
// Sign the certificate
|
||||
const cert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: this.randomSerialNumber(),
|
||||
subject: csr.subject,
|
||||
issuer: this.caCert.subject,
|
||||
notBefore,
|
||||
notAfter,
|
||||
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
|
||||
publicKey: csr.publicKey,
|
||||
signingKey: this.caKeyPair.privateKey,
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||
new x509.KeyUsagesExtension(
|
||||
x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
|
||||
true,
|
||||
),
|
||||
new x509.ExtendedKeyUsageExtension(
|
||||
['1.3.6.1.5.5.7.3.1'], // serverAuth
|
||||
true,
|
||||
),
|
||||
new x509.SubjectAlternativeNameExtension(sanNames),
|
||||
await x509.AuthorityKeyIdentifierExtension.create(this.caKeyPair.publicKey),
|
||||
],
|
||||
});
|
||||
|
||||
// Return PEM chain: end-entity cert + root CA cert
|
||||
const certPem = cert.toString('pem');
|
||||
return `${certPem}\n${this.caCertPem}`;
|
||||
}
|
||||
|
||||
getCaCertPem(): string {
|
||||
return this.caCertPem;
|
||||
}
|
||||
|
||||
private randomSerialNumber(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
}
|
||||
61
ts_server/server.classes.challenge.verifier.ts
Normal file
61
ts_server/server.classes.challenge.verifier.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as http from 'node:http';
|
||||
|
||||
/**
|
||||
* Verifies ACME challenges by making HTTP requests or DNS lookups.
|
||||
*/
|
||||
export class ChallengeVerifier {
|
||||
private verificationEnabled: boolean;
|
||||
|
||||
constructor(verificationEnabled = true) {
|
||||
this.verificationEnabled = verificationEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an HTTP-01 challenge by fetching the token from the domain.
|
||||
*/
|
||||
async verifyHttp01(domain: string, token: string, expectedKeyAuth: string): Promise<boolean> {
|
||||
if (!this.verificationEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `http://${domain}/.well-known/acme-challenge/${token}`;
|
||||
const body = await this.httpGet(url);
|
||||
return body.trim() === expectedKeyAuth.trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a DNS-01 challenge by looking up the TXT record.
|
||||
*/
|
||||
async verifyDns01(domain: string, expectedHash: string): Promise<boolean> {
|
||||
if (!this.verificationEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const { promises: dns } = await import('node:dns');
|
||||
const records = await dns.resolveTxt(`_acme-challenge.${domain}`);
|
||||
const flatRecords = records.map((r) => r.join(''));
|
||||
return flatRecords.some((r) => r === expectedHash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private httpGet(url: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(url, { timeout: 10000 }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('HTTP-01 verification timeout'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
153
ts_server/server.classes.jws.verifier.ts
Normal file
153
ts_server/server.classes.jws.verifier.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
||||
import type { NonceManager } from './server.classes.nonce.js';
|
||||
import type { IServerAccountStore } from './server.interfaces.js';
|
||||
|
||||
export interface IJwsVerifyResult {
|
||||
jwk: Record<string, string>;
|
||||
kid: string | null;
|
||||
thumbprint: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies JWS-signed ACME requests.
|
||||
* This is the inverse of AcmeCrypto.createJws().
|
||||
*/
|
||||
export class JwsVerifier {
|
||||
private nonceManager: NonceManager;
|
||||
private accountStore: IServerAccountStore;
|
||||
|
||||
constructor(nonceManager: NonceManager, accountStore: IServerAccountStore) {
|
||||
this.nonceManager = nonceManager;
|
||||
this.accountStore = accountStore;
|
||||
}
|
||||
|
||||
async verify(
|
||||
body: { protected: string; payload: string; signature: string },
|
||||
expectedUrl: string,
|
||||
): Promise<IJwsVerifyResult> {
|
||||
if (!body || !body.protected || body.signature === undefined) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS structure');
|
||||
}
|
||||
|
||||
// 1. Decode protected header
|
||||
const headerJson = Buffer.from(body.protected, 'base64url').toString('utf-8');
|
||||
let header: Record<string, any>;
|
||||
try {
|
||||
header = JSON.parse(headerJson);
|
||||
} catch {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS protected header');
|
||||
}
|
||||
|
||||
// 2. Validate required fields
|
||||
const { alg, nonce, url, jwk, kid } = header;
|
||||
if (!alg) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing alg in protected header');
|
||||
}
|
||||
if (!nonce) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Missing nonce in protected header');
|
||||
}
|
||||
if (!url) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing url in protected header');
|
||||
}
|
||||
|
||||
// 3. Validate URL matches
|
||||
if (url !== expectedUrl) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:malformed',
|
||||
`URL mismatch: expected ${expectedUrl}, got ${url}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Validate and consume nonce
|
||||
if (!this.nonceManager.consume(nonce)) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Invalid or expired nonce');
|
||||
}
|
||||
|
||||
// 5. Must have exactly one of jwk or kid
|
||||
if (jwk && kid) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid, not both');
|
||||
}
|
||||
if (!jwk && !kid) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid');
|
||||
}
|
||||
|
||||
// 6. Resolve the public key
|
||||
let resolvedJwk: Record<string, string>;
|
||||
if (jwk) {
|
||||
resolvedJwk = jwk;
|
||||
} else {
|
||||
// Look up account by kid (account URL)
|
||||
const account = await this.accountStore.getByUrl(kid);
|
||||
if (!account) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:accountDoesNotExist',
|
||||
'Account not found for kid',
|
||||
);
|
||||
}
|
||||
resolvedJwk = account.jwk;
|
||||
}
|
||||
|
||||
// 7. Reconstruct public key and verify signature
|
||||
const publicKey = crypto.createPublicKey({ key: resolvedJwk, format: 'jwk' });
|
||||
const signingInput = `${body.protected}.${body.payload}`;
|
||||
const signatureBuffer = Buffer.from(body.signature, 'base64url');
|
||||
|
||||
const supportedAlgs = ['RS256', 'ES256', 'ES384'];
|
||||
if (!supportedAlgs.includes(alg)) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badSignatureAlgorithm', `Unsupported algorithm: ${alg}`);
|
||||
}
|
||||
|
||||
let valid: boolean;
|
||||
if (alg.startsWith('RS')) {
|
||||
valid = crypto.verify('sha256', Buffer.from(signingInput), publicKey, signatureBuffer);
|
||||
} else {
|
||||
valid = crypto.verify('sha256', Buffer.from(signingInput), { key: publicKey, dsaEncoding: 'ieee-p1363' }, signatureBuffer);
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
throw new AcmeServerError(403, 'urn:ietf:params:acme:error:unauthorized', 'Invalid JWS signature');
|
||||
}
|
||||
|
||||
// 8. Decode payload
|
||||
let payload: any;
|
||||
if (body.payload === '') {
|
||||
payload = null; // POST-as-GET
|
||||
} else {
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(body.payload, 'base64url').toString('utf-8'));
|
||||
} catch {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS payload');
|
||||
}
|
||||
}
|
||||
|
||||
const thumbprint = AcmeCrypto.getJwkThumbprint(resolvedJwk);
|
||||
|
||||
return {
|
||||
jwk: resolvedJwk,
|
||||
kid: kid || null,
|
||||
thumbprint,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple error class for ACME server errors that maps to RFC 8555 problem responses.
|
||||
*/
|
||||
export class AcmeServerError extends Error {
|
||||
public readonly status: number;
|
||||
public readonly type: string;
|
||||
public readonly detail: string;
|
||||
|
||||
constructor(status: number, type: string, detail: string) {
|
||||
super(`${type}: ${detail}`);
|
||||
this.name = 'AcmeServerError';
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
36
ts_server/server.classes.nonce.ts
Normal file
36
ts_server/server.classes.nonce.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Manages ACME replay nonces.
|
||||
* Each nonce is single-use: consumed on verification, fresh one issued with every response.
|
||||
*/
|
||||
export class NonceManager {
|
||||
private nonces = new Set<string>();
|
||||
private nonceQueue: string[] = [];
|
||||
private maxSize: number;
|
||||
|
||||
constructor(maxSize = 10000) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
generate(): string {
|
||||
const nonce = crypto.randomBytes(16).toString('base64url');
|
||||
if (this.nonces.size >= this.maxSize) {
|
||||
const oldest = this.nonceQueue.shift();
|
||||
if (oldest) {
|
||||
this.nonces.delete(oldest);
|
||||
}
|
||||
}
|
||||
this.nonces.add(nonce);
|
||||
this.nonceQueue.push(nonce);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
consume(nonce: string): boolean {
|
||||
if (this.nonces.has(nonce)) {
|
||||
this.nonces.delete(nonce);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
72
ts_server/server.classes.order.store.ts
Normal file
72
ts_server/server.classes.order.store.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
IServerOrderStore,
|
||||
IServerOrder,
|
||||
IServerAuthorization,
|
||||
IServerChallenge,
|
||||
} from './server.interfaces.js';
|
||||
|
||||
/**
|
||||
* In-memory order/authorization/challenge/certificate storage for the ACME server.
|
||||
*/
|
||||
export class MemoryOrderStore implements IServerOrderStore {
|
||||
private orders = new Map<string, IServerOrder>();
|
||||
private authorizations = new Map<string, IServerAuthorization>();
|
||||
private challenges = new Map<string, IServerChallenge>();
|
||||
private certPems = new Map<string, string>();
|
||||
|
||||
async createOrder(order: IServerOrder): Promise<IServerOrder> {
|
||||
this.orders.set(order.id, order);
|
||||
return order;
|
||||
}
|
||||
|
||||
async getOrder(id: string): Promise<IServerOrder | null> {
|
||||
return this.orders.get(id) || null;
|
||||
}
|
||||
|
||||
async updateOrder(id: string, updates: Partial<IServerOrder>): Promise<void> {
|
||||
const order = this.orders.get(id);
|
||||
if (order) {
|
||||
Object.assign(order, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async createAuthorization(authz: IServerAuthorization): Promise<IServerAuthorization> {
|
||||
this.authorizations.set(authz.id, authz);
|
||||
return authz;
|
||||
}
|
||||
|
||||
async getAuthorization(id: string): Promise<IServerAuthorization | null> {
|
||||
return this.authorizations.get(id) || null;
|
||||
}
|
||||
|
||||
async updateAuthorization(id: string, updates: Partial<IServerAuthorization>): Promise<void> {
|
||||
const authz = this.authorizations.get(id);
|
||||
if (authz) {
|
||||
Object.assign(authz, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async createChallenge(challenge: IServerChallenge): Promise<IServerChallenge> {
|
||||
this.challenges.set(challenge.id, challenge);
|
||||
return challenge;
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<IServerChallenge | null> {
|
||||
return this.challenges.get(id) || null;
|
||||
}
|
||||
|
||||
async updateChallenge(id: string, updates: Partial<IServerChallenge>): Promise<void> {
|
||||
const challenge = this.challenges.get(id);
|
||||
if (challenge) {
|
||||
Object.assign(challenge, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async storeCertPem(orderId: string, pem: string): Promise<void> {
|
||||
this.certPems.set(orderId, pem);
|
||||
}
|
||||
|
||||
async getCertPem(orderId: string): Promise<string | null> {
|
||||
return this.certPems.get(orderId) || null;
|
||||
}
|
||||
}
|
||||
116
ts_server/server.classes.router.ts
Normal file
116
ts_server/server.classes.router.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type * as http from 'node:http';
|
||||
import type { TRouteHandler } from './server.interfaces.js';
|
||||
import type { NonceManager } from './server.classes.nonce.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
|
||||
interface IRoute {
|
||||
method: string;
|
||||
pattern: string;
|
||||
segments: string[];
|
||||
handler: TRouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal HTTP router for the ACME server.
|
||||
* Supports parameterized paths like /order/:id.
|
||||
*/
|
||||
export class AcmeRouter {
|
||||
private routes: IRoute[] = [];
|
||||
private nonceManager: NonceManager;
|
||||
|
||||
constructor(nonceManager: NonceManager) {
|
||||
this.nonceManager = nonceManager;
|
||||
}
|
||||
|
||||
addRoute(method: string, pattern: string, handler: TRouteHandler): void {
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern,
|
||||
segments: pattern.split('/').filter(Boolean),
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Always add a fresh nonce to every response
|
||||
res.setHeader('Replay-Nonce', this.nonceManager.generate());
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
|
||||
// Find matching route
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method) continue;
|
||||
const params = this.matchPath(route.segments, pathSegments);
|
||||
if (params === null) continue;
|
||||
|
||||
try {
|
||||
const body = method === 'POST' ? await this.parseBody(req) : undefined;
|
||||
await route.handler(req, res, params, body);
|
||||
} catch (err) {
|
||||
this.sendError(res, err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No route found
|
||||
this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found'));
|
||||
}
|
||||
|
||||
private matchPath(
|
||||
routeSegments: string[],
|
||||
pathSegments: string[],
|
||||
): Record<string, string> | null {
|
||||
if (routeSegments.length !== pathSegments.length) return null;
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < routeSegments.length; i++) {
|
||||
if (routeSegments[i].startsWith(':')) {
|
||||
params[routeSegments[i].slice(1)] = pathSegments[i];
|
||||
} else if (routeSegments[i] !== pathSegments[i]) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
private parseBody(req: http.IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if (!raw) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch {
|
||||
reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
private sendError(res: http.ServerResponse, err: unknown): void {
|
||||
if (err instanceof AcmeServerError) {
|
||||
res.writeHead(err.status, { 'Content-Type': 'application/problem+json' });
|
||||
res.end(JSON.stringify({
|
||||
type: err.type,
|
||||
detail: err.detail,
|
||||
status: err.status,
|
||||
}));
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : 'Internal server error';
|
||||
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
|
||||
res.end(JSON.stringify({
|
||||
type: 'urn:ietf:params:acme:error:serverInternal',
|
||||
detail: message,
|
||||
status: 500,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
85
ts_server/server.handlers.account.ts
Normal file
85
ts_server/server.handlers.account.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
||||
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
import type { IServerAccountStore } from './server.interfaces.js';
|
||||
|
||||
/**
|
||||
* POST /new-account — Register or retrieve an ACME account.
|
||||
* Expects JWS with JWK in protected header (not kid).
|
||||
*/
|
||||
export function createAccountHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
accountStore: IServerAccountStore,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
_params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const requestUrl = `${baseUrl}/new-account`;
|
||||
const verified = await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
// Account creation must use JWK, not kid
|
||||
if (verified.kid) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:malformed',
|
||||
'newAccount requests must use JWK, not kid',
|
||||
);
|
||||
}
|
||||
|
||||
const { payload, jwk, thumbprint } = verified;
|
||||
|
||||
// Check if account already exists
|
||||
const existing = await accountStore.getByThumbprint(thumbprint);
|
||||
if (existing) {
|
||||
// If onlyReturnExisting, or just returning the existing account
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Location': existing.url,
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
status: existing.status,
|
||||
contact: existing.contact,
|
||||
orders: `${baseUrl}/account/${existing.id}/orders`,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// onlyReturnExisting = true but no account found
|
||||
if (payload?.onlyReturnExisting) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:accountDoesNotExist',
|
||||
'Account does not exist',
|
||||
);
|
||||
}
|
||||
|
||||
// Create new account
|
||||
const id = crypto.randomBytes(16).toString('hex');
|
||||
const accountUrl = `${baseUrl}/account/${id}`;
|
||||
const account = await accountStore.create({
|
||||
id,
|
||||
thumbprint,
|
||||
url: accountUrl,
|
||||
jwk,
|
||||
status: 'valid',
|
||||
contact: payload?.contact || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.writeHead(201, {
|
||||
'Content-Type': 'application/json',
|
||||
'Location': accountUrl,
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
status: account.status,
|
||||
contact: account.contact,
|
||||
orders: `${baseUrl}/account/${id}/orders`,
|
||||
}));
|
||||
};
|
||||
}
|
||||
58
ts_server/server.handlers.authz.ts
Normal file
58
ts_server/server.handlers.authz.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as http from 'node:http';
|
||||
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
import type { IServerOrderStore } from './server.interfaces.js';
|
||||
|
||||
/**
|
||||
* POST /authz/:id — Return authorization with embedded challenges (POST-as-GET).
|
||||
*/
|
||||
export function createAuthzHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
orderStore: IServerOrderStore,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const authzId = params.id;
|
||||
const requestUrl = `${baseUrl}/authz/${authzId}`;
|
||||
await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
const authz = await orderStore.getAuthorization(authzId);
|
||||
if (!authz) {
|
||||
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Authorization not found');
|
||||
}
|
||||
|
||||
// Build challenge objects
|
||||
const challenges = [];
|
||||
for (const challengeId of authz.challengeIds) {
|
||||
const challenge = await orderStore.getChallenge(challengeId);
|
||||
if (challenge) {
|
||||
challenges.push({
|
||||
type: challenge.type,
|
||||
url: `${baseUrl}/challenge/${challenge.id}`,
|
||||
status: challenge.status,
|
||||
token: challenge.token,
|
||||
...(challenge.validated ? { validated: challenge.validated } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const responseBody: Record<string, any> = {
|
||||
identifier: authz.identifier,
|
||||
status: authz.status,
|
||||
expires: authz.expires,
|
||||
challenges,
|
||||
};
|
||||
|
||||
if (authz.wildcard) {
|
||||
responseBody.wildcard = true;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(responseBody));
|
||||
};
|
||||
}
|
||||
32
ts_server/server.handlers.cert.ts
Normal file
32
ts_server/server.handlers.cert.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type * as http from 'node:http';
|
||||
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
import type { IServerOrderStore } from './server.interfaces.js';
|
||||
|
||||
/**
|
||||
* POST /cert/:id — Download certificate chain (POST-as-GET).
|
||||
*/
|
||||
export function createCertHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
orderStore: IServerOrderStore,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const orderId = params.id;
|
||||
const requestUrl = `${baseUrl}/cert/${orderId}`;
|
||||
await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
const certPem = await orderStore.getCertPem(orderId);
|
||||
if (!certPem) {
|
||||
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Certificate not found');
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/pem-certificate-chain' });
|
||||
res.end(certPem);
|
||||
};
|
||||
}
|
||||
142
ts_server/server.handlers.challenge.ts
Normal file
142
ts_server/server.handlers.challenge.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
||||
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
import type { IServerOrderStore, IServerAccountStore } from './server.interfaces.js';
|
||||
import type { ChallengeVerifier } from './server.classes.challenge.verifier.js';
|
||||
|
||||
/**
|
||||
* POST /challenge/:id — Trigger or poll an ACME challenge.
|
||||
* - POST with `{}` payload: trigger challenge validation
|
||||
* - POST-as-GET (null payload): return current challenge state
|
||||
*/
|
||||
export function createChallengeHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
orderStore: IServerOrderStore,
|
||||
accountStore: IServerAccountStore,
|
||||
challengeVerifier: ChallengeVerifier,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const challengeId = params.id;
|
||||
const requestUrl = `${baseUrl}/challenge/${challengeId}`;
|
||||
const verified = await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
const challenge = await orderStore.getChallenge(challengeId);
|
||||
if (!challenge) {
|
||||
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Challenge not found');
|
||||
}
|
||||
|
||||
// POST-as-GET: just return current state
|
||||
if (verified.payload === null) {
|
||||
sendChallengeResponse(res, challenge, baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger validation (payload should be `{}`)
|
||||
if (challenge.status !== 'pending') {
|
||||
// Already processing or completed
|
||||
sendChallengeResponse(res, challenge, baseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set to processing
|
||||
await orderStore.updateChallenge(challengeId, { status: 'processing' });
|
||||
|
||||
// Get the authorization to find the domain
|
||||
const authz = await orderStore.getAuthorization(challenge.authorizationId);
|
||||
if (!authz) {
|
||||
throw new AcmeServerError(500, 'urn:ietf:params:acme:error:serverInternal', 'Authorization not found');
|
||||
}
|
||||
|
||||
// Resolve the account's JWK for key authorization computation
|
||||
const account = await accountStore.getByUrl(verified.kid!);
|
||||
if (!account) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:accountDoesNotExist', 'Account not found');
|
||||
}
|
||||
|
||||
const thumbprint = AcmeCrypto.getJwkThumbprint(account.jwk);
|
||||
const keyAuth = `${challenge.token}.${thumbprint}`;
|
||||
|
||||
// Verify the challenge
|
||||
let valid = false;
|
||||
const domain = authz.identifier.value;
|
||||
|
||||
if (challenge.type === 'http-01') {
|
||||
valid = await challengeVerifier.verifyHttp01(domain, challenge.token, keyAuth);
|
||||
} else if (challenge.type === 'dns-01') {
|
||||
const hash = crypto.createHash('sha256').update(keyAuth).digest('base64url');
|
||||
valid = await challengeVerifier.verifyDns01(domain, hash);
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
await orderStore.updateChallenge(challengeId, {
|
||||
status: 'valid',
|
||||
validated: new Date().toISOString(),
|
||||
});
|
||||
challenge.status = 'valid';
|
||||
challenge.validated = new Date().toISOString();
|
||||
|
||||
// Check if all challenges for this authorization's required type are valid
|
||||
// One valid challenge is enough to validate the authorization
|
||||
await orderStore.updateAuthorization(authz.id, { status: 'valid' });
|
||||
|
||||
// Check if all authorizations for the order are now valid
|
||||
const order = await orderStore.getOrder(authz.orderId);
|
||||
if (order && order.status === 'pending') {
|
||||
let allValid = true;
|
||||
for (const authzId of order.authorizationIds) {
|
||||
const a = await orderStore.getAuthorization(authzId);
|
||||
if (!a || a.status !== 'valid') {
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allValid) {
|
||||
await orderStore.updateOrder(order.id, { status: 'ready' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await orderStore.updateChallenge(challengeId, {
|
||||
status: 'invalid',
|
||||
error: {
|
||||
type: 'urn:ietf:params:acme:error:incorrectResponse',
|
||||
detail: `Challenge verification failed for ${domain}`,
|
||||
},
|
||||
});
|
||||
challenge.status = 'invalid';
|
||||
|
||||
// Mark authorization as invalid too
|
||||
await orderStore.updateAuthorization(authz.id, { status: 'invalid' });
|
||||
}
|
||||
|
||||
sendChallengeResponse(res, challenge, baseUrl);
|
||||
};
|
||||
}
|
||||
|
||||
function sendChallengeResponse(
|
||||
res: http.ServerResponse,
|
||||
challenge: { id: string; type: string; status: string; token: string; validated?: string },
|
||||
baseUrl: string,
|
||||
): void {
|
||||
const responseBody: Record<string, any> = {
|
||||
type: challenge.type,
|
||||
url: `${baseUrl}/challenge/${challenge.id}`,
|
||||
status: challenge.status,
|
||||
token: challenge.token,
|
||||
};
|
||||
if (challenge.validated) {
|
||||
responseBody.validated = challenge.validated;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Link': `<${baseUrl}/directory>;rel="index"`,
|
||||
});
|
||||
res.end(JSON.stringify(responseBody));
|
||||
}
|
||||
30
ts_server/server.handlers.directory.ts
Normal file
30
ts_server/server.handlers.directory.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type * as http from 'node:http';
|
||||
import type { IAcmeDirectory } from '../ts/acme/acme.interfaces.js';
|
||||
|
||||
/**
|
||||
* GET /directory — Returns the ACME directory object with all endpoint URLs.
|
||||
*/
|
||||
export function createDirectoryHandler(baseUrl: string) {
|
||||
return async (
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
_params: Record<string, string>,
|
||||
_body: any,
|
||||
): Promise<void> => {
|
||||
const directory: IAcmeDirectory = {
|
||||
newNonce: `${baseUrl}/new-nonce`,
|
||||
newAccount: `${baseUrl}/new-account`,
|
||||
newOrder: `${baseUrl}/new-order`,
|
||||
revokeCert: `${baseUrl}/revoke-cert`,
|
||||
keyChange: `${baseUrl}/key-change`,
|
||||
meta: {
|
||||
termsOfService: `${baseUrl}/terms`,
|
||||
website: `${baseUrl}`,
|
||||
caaIdentities: [],
|
||||
externalAccountRequired: false,
|
||||
},
|
||||
};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(directory));
|
||||
};
|
||||
}
|
||||
93
ts_server/server.handlers.finalize.ts
Normal file
93
ts_server/server.handlers.finalize.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type * as http from 'node:http';
|
||||
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
import type { IServerOrderStore } from './server.interfaces.js';
|
||||
import type { AcmeServerCA } from './server.classes.ca.js';
|
||||
|
||||
/**
|
||||
* POST /finalize/:id — Submit CSR and issue certificate.
|
||||
*/
|
||||
export function createFinalizeHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
orderStore: IServerOrderStore,
|
||||
ca: AcmeServerCA,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const orderId = params.id;
|
||||
const requestUrl = `${baseUrl}/finalize/${orderId}`;
|
||||
const verified = await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
if (!verified.kid) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Finalize requires kid');
|
||||
}
|
||||
|
||||
const order = await orderStore.getOrder(orderId);
|
||||
if (!order) {
|
||||
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Order not found');
|
||||
}
|
||||
|
||||
// Check all authorizations are valid and update order status if needed
|
||||
if (order.status === 'pending') {
|
||||
let allValid = true;
|
||||
for (const authzId of order.authorizationIds) {
|
||||
const authz = await orderStore.getAuthorization(authzId);
|
||||
if (!authz || authz.status !== 'valid') {
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allValid) {
|
||||
await orderStore.updateOrder(orderId, { status: 'ready' });
|
||||
order.status = 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status !== 'ready') {
|
||||
throw new AcmeServerError(
|
||||
403,
|
||||
'urn:ietf:params:acme:error:orderNotReady',
|
||||
`Order is in "${order.status}" state, expected "ready"`,
|
||||
);
|
||||
}
|
||||
|
||||
const { payload } = verified;
|
||||
if (!payload?.csr) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing CSR in finalize request');
|
||||
}
|
||||
|
||||
// Transition to processing
|
||||
await orderStore.updateOrder(orderId, { status: 'processing' });
|
||||
|
||||
// Sign the certificate
|
||||
const certPem = await ca.signCsr(payload.csr);
|
||||
|
||||
// Store certificate and update order
|
||||
const certUrl = `${baseUrl}/cert/${orderId}`;
|
||||
await orderStore.storeCertPem(orderId, certPem);
|
||||
await orderStore.updateOrder(orderId, {
|
||||
status: 'valid',
|
||||
certificate: certUrl,
|
||||
});
|
||||
|
||||
const responseBody = {
|
||||
status: 'valid',
|
||||
expires: order.expires,
|
||||
identifiers: order.identifiers,
|
||||
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
|
||||
finalize: order.finalize,
|
||||
certificate: certUrl,
|
||||
};
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Location': `${baseUrl}/order/${orderId}`,
|
||||
});
|
||||
res.end(JSON.stringify(responseBody));
|
||||
};
|
||||
}
|
||||
29
ts_server/server.handlers.nonce.ts
Normal file
29
ts_server/server.handlers.nonce.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type * as http from 'node:http';
|
||||
|
||||
/**
|
||||
* HEAD /new-nonce — Returns 200 with Replay-Nonce header (added by router).
|
||||
* GET /new-nonce — Returns 204 with Replay-Nonce header (added by router).
|
||||
*/
|
||||
export function createNonceHeadHandler() {
|
||||
return async (
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
_params: Record<string, string>,
|
||||
_body: any,
|
||||
): Promise<void> => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
||||
res.end();
|
||||
};
|
||||
}
|
||||
|
||||
export function createNonceGetHandler() {
|
||||
return async (
|
||||
_req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
_params: Record<string, string>,
|
||||
_body: any,
|
||||
): Promise<void> => {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
};
|
||||
}
|
||||
177
ts_server/server.handlers.order.ts
Normal file
177
ts_server/server.handlers.order.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type * as http from 'node:http';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
||||
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
||||
import type { IServerOrderStore, IServerAccountStore } from './server.interfaces.js';
|
||||
import type { IAcmeIdentifier } from '../ts/acme/acme.interfaces.js';
|
||||
|
||||
/**
|
||||
* POST /new-order — Create a new ACME order.
|
||||
*/
|
||||
export function createNewOrderHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
orderStore: IServerOrderStore,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
_params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const requestUrl = `${baseUrl}/new-order`;
|
||||
const verified = await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
if (!verified.kid) {
|
||||
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'newOrder requires kid');
|
||||
}
|
||||
|
||||
const { payload } = verified;
|
||||
const identifiers: IAcmeIdentifier[] = payload?.identifiers;
|
||||
|
||||
if (!identifiers || !Array.isArray(identifiers) || identifiers.length === 0) {
|
||||
throw new AcmeServerError(
|
||||
400,
|
||||
'urn:ietf:params:acme:error:malformed',
|
||||
'Order must include at least one identifier',
|
||||
);
|
||||
}
|
||||
|
||||
const orderId = crypto.randomBytes(16).toString('hex');
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + 7);
|
||||
|
||||
// Create authorizations and challenges for each identifier
|
||||
const authorizationIds: string[] = [];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
const authzId = crypto.randomBytes(16).toString('hex');
|
||||
const isWildcard = identifier.value.startsWith('*.');
|
||||
const domain = isWildcard ? identifier.value.slice(2) : identifier.value;
|
||||
|
||||
// Create challenges for this authorization
|
||||
const challengeIds: string[] = [];
|
||||
|
||||
// HTTP-01 challenge (not for wildcards)
|
||||
if (!isWildcard) {
|
||||
const http01Id = crypto.randomBytes(16).toString('hex');
|
||||
const http01Token = crypto.randomBytes(32).toString('base64url');
|
||||
await orderStore.createChallenge({
|
||||
id: http01Id,
|
||||
authorizationId: authzId,
|
||||
type: 'http-01',
|
||||
token: http01Token,
|
||||
status: 'pending',
|
||||
});
|
||||
challengeIds.push(http01Id);
|
||||
}
|
||||
|
||||
// DNS-01 challenge (always)
|
||||
const dns01Id = crypto.randomBytes(16).toString('hex');
|
||||
const dns01Token = crypto.randomBytes(32).toString('base64url');
|
||||
await orderStore.createChallenge({
|
||||
id: dns01Id,
|
||||
authorizationId: authzId,
|
||||
type: 'dns-01',
|
||||
token: dns01Token,
|
||||
status: 'pending',
|
||||
});
|
||||
challengeIds.push(dns01Id);
|
||||
|
||||
await orderStore.createAuthorization({
|
||||
id: authzId,
|
||||
orderId,
|
||||
identifier: { type: 'dns', value: domain },
|
||||
status: 'pending',
|
||||
expires: expires.toISOString(),
|
||||
challengeIds,
|
||||
wildcard: isWildcard || undefined,
|
||||
});
|
||||
|
||||
authorizationIds.push(authzId);
|
||||
}
|
||||
|
||||
const order = await orderStore.createOrder({
|
||||
id: orderId,
|
||||
accountUrl: verified.kid,
|
||||
status: 'pending',
|
||||
identifiers,
|
||||
authorizationIds,
|
||||
expires: expires.toISOString(),
|
||||
finalize: `${baseUrl}/finalize/${orderId}`,
|
||||
});
|
||||
|
||||
const responseBody = {
|
||||
status: order.status,
|
||||
expires: order.expires,
|
||||
identifiers: order.identifiers,
|
||||
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
|
||||
finalize: order.finalize,
|
||||
};
|
||||
|
||||
res.writeHead(201, {
|
||||
'Content-Type': 'application/json',
|
||||
'Location': `${baseUrl}/order/${orderId}`,
|
||||
});
|
||||
res.end(JSON.stringify(responseBody));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /order/:id — Poll order status (POST-as-GET).
|
||||
*/
|
||||
export function createOrderPollHandler(
|
||||
baseUrl: string,
|
||||
jwsVerifier: JwsVerifier,
|
||||
orderStore: IServerOrderStore,
|
||||
) {
|
||||
return async (
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body: any,
|
||||
): Promise<void> => {
|
||||
const orderId = params.id;
|
||||
const requestUrl = `${baseUrl}/order/${orderId}`;
|
||||
await jwsVerifier.verify(body, requestUrl);
|
||||
|
||||
const order = await orderStore.getOrder(orderId);
|
||||
if (!order) {
|
||||
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Order not found');
|
||||
}
|
||||
|
||||
// Check if all authorizations are valid → transition to ready
|
||||
if (order.status === 'pending') {
|
||||
let allValid = true;
|
||||
for (const authzId of order.authorizationIds) {
|
||||
const authz = await orderStore.getAuthorization(authzId);
|
||||
if (!authz || authz.status !== 'valid') {
|
||||
allValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allValid) {
|
||||
await orderStore.updateOrder(orderId, { status: 'ready' });
|
||||
order.status = 'ready';
|
||||
}
|
||||
}
|
||||
|
||||
const responseBody: Record<string, any> = {
|
||||
status: order.status,
|
||||
expires: order.expires,
|
||||
identifiers: order.identifiers,
|
||||
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
|
||||
finalize: order.finalize,
|
||||
};
|
||||
|
||||
if (order.certificate) {
|
||||
responseBody.certificate = order.certificate;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Location': requestUrl,
|
||||
});
|
||||
res.end(JSON.stringify(responseBody));
|
||||
};
|
||||
}
|
||||
98
ts_server/server.interfaces.ts
Normal file
98
ts_server/server.interfaces.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { IAcmeIdentifier } from '../ts/acme/acme.interfaces.js';
|
||||
|
||||
// ============================================================================
|
||||
// Server configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface IAcmeServerOptions {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
baseUrl?: string;
|
||||
/** When false, challenges auto-approve on trigger (useful for testing) */
|
||||
challengeVerification?: boolean;
|
||||
caOptions?: {
|
||||
commonName?: string;
|
||||
validityDays?: number;
|
||||
certValidityDays?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pluggable storage interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface IServerAccountStore {
|
||||
create(account: IServerAccount): Promise<IServerAccount>;
|
||||
getByThumbprint(thumbprint: string): Promise<IServerAccount | null>;
|
||||
getByUrl(url: string): Promise<IServerAccount | null>;
|
||||
}
|
||||
|
||||
export interface IServerOrderStore {
|
||||
createOrder(order: IServerOrder): Promise<IServerOrder>;
|
||||
getOrder(id: string): Promise<IServerOrder | null>;
|
||||
updateOrder(id: string, updates: Partial<IServerOrder>): Promise<void>;
|
||||
createAuthorization(authz: IServerAuthorization): Promise<IServerAuthorization>;
|
||||
getAuthorization(id: string): Promise<IServerAuthorization | null>;
|
||||
updateAuthorization(id: string, updates: Partial<IServerAuthorization>): Promise<void>;
|
||||
createChallenge(challenge: IServerChallenge): Promise<IServerChallenge>;
|
||||
getChallenge(id: string): Promise<IServerChallenge | null>;
|
||||
updateChallenge(id: string, updates: Partial<IServerChallenge>): Promise<void>;
|
||||
storeCertPem(orderId: string, pem: string): Promise<void>;
|
||||
getCertPem(orderId: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal server models
|
||||
// ============================================================================
|
||||
|
||||
export interface IServerAccount {
|
||||
id: string;
|
||||
thumbprint: string;
|
||||
url: string;
|
||||
jwk: Record<string, string>;
|
||||
status: string;
|
||||
contact: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IServerOrder {
|
||||
id: string;
|
||||
accountUrl: string;
|
||||
status: string;
|
||||
identifiers: IAcmeIdentifier[];
|
||||
authorizationIds: string[];
|
||||
expires: string;
|
||||
finalize: string;
|
||||
certificate?: string;
|
||||
}
|
||||
|
||||
export interface IServerAuthorization {
|
||||
id: string;
|
||||
orderId: string;
|
||||
identifier: IAcmeIdentifier;
|
||||
status: string;
|
||||
expires: string;
|
||||
challengeIds: string[];
|
||||
wildcard?: boolean;
|
||||
}
|
||||
|
||||
export interface IServerChallenge {
|
||||
id: string;
|
||||
authorizationId: string;
|
||||
type: string;
|
||||
token: string;
|
||||
status: string;
|
||||
validated?: string;
|
||||
error?: { type: string; detail: string };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Route handler type
|
||||
// ============================================================================
|
||||
|
||||
export type TRouteHandler = (
|
||||
req: import('node:http').IncomingMessage,
|
||||
res: import('node:http').ServerResponse,
|
||||
params: Record<string, string>,
|
||||
body: any,
|
||||
) => Promise<void>;
|
||||
Reference in New Issue
Block a user