Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e830cb6252 | |||
| 75def30b0a | |||
| ab0ca6ccc3 | |||
| e570ac6db0 |
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
14
changelog.md
14
changelog.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-27 - 9.3.1 - fix(acme)
|
||||
parse issued certificate expiry from X.509 metadata and update build compatibility for dependency upgrades
|
||||
|
||||
- Store certificate validity using the actual X.509 expiration date instead of a fixed 90-day estimate, with a fallback if PEM parsing fails.
|
||||
- Add reflect-metadata imports and declare it as a direct dependency to support @peculiar/x509 v2.
|
||||
- Update TypeScript and build configuration for newer toolchain requirements, including Node types and renamed project config file.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
32
package.json
32
package.json
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@push.rocks/smartacme",
|
||||
"version": "9.2.0",
|
||||
"version": "9.3.1",
|
||||
"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",
|
||||
@@ -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",
|
||||
@@ -40,23 +45,24 @@
|
||||
"homepage": "https://code.foss.global/push.rocks/smartacme#readme",
|
||||
"dependencies": {
|
||||
"@apiclient.xyz/cloudflare": "^7.1.0",
|
||||
"@peculiar/x509": "^1.14.3",
|
||||
"@push.rocks/lik": "^6.3.1",
|
||||
"@push.rocks/smartdata": "^7.1.0",
|
||||
"@peculiar/x509": "^2.0.0",
|
||||
"@push.rocks/lik": "^6.4.0",
|
||||
"@push.rocks/smartdata": "^7.1.3",
|
||||
"@push.rocks/smartdelay": "^3.0.5",
|
||||
"@push.rocks/smartdns": "^7.9.0",
|
||||
"@push.rocks/smartlog": "^3.2.1",
|
||||
"@push.rocks/smartnetwork": "^4.4.0",
|
||||
"@push.rocks/smartnetwork": "^4.5.2",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"@push.rocks/taskbuffer": "^6.1.2",
|
||||
"@tsclass/tsclass": "^9.5.0"
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@tsclass/tsclass": "^9.5.0",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tstest": "^3.4.0",
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
@@ -69,7 +75,7 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
".smartconfig.json",
|
||||
"readme.md"
|
||||
],
|
||||
"browserslist": [
|
||||
|
||||
639
pnpm-lock.yaml
generated
639
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -43,10 +43,39 @@ Key implementation details:
|
||||
- `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.4.0+) — auto-discovers and compiles `ts/` and `ts_server/` directories
|
||||
- `@peculiar/x509` v2.0.0 removed `reflect-metadata` from its dependencies. Since `tsyringe` (used internally by `@peculiar/x509`) requires the Reflect polyfill, `reflect-metadata` is now a direct dependency and imported in `ts/acme/acme.classes.crypto.ts` and `ts_server/server.classes.ca.ts`.
|
||||
- `@push.rocks/taskbuffer` upgraded from v6 to v8 (required by smartdata 7.1.3). API surface is backward-compatible.
|
||||
- TypeScript 6 (via tsbuild 4.4.0) requires `"types": ["node"]` in tsconfig.json for `ts_server/` compilation to resolve `@types/node`.
|
||||
- TypeScript 6 deprecated `baseUrl` in tsconfig — removed it since `paths` was empty.
|
||||
- Config file renamed from `npmextra.json` to `.smartconfig.json` (ecosystem convention change).
|
||||
|
||||
133
readme.md
133
readme.md
@@ -1,6 +1,6 @@
|
||||
# @push.rocks/smartacme
|
||||
|
||||
A TypeScript-based ACME client for Let's Encrypt 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
|
||||
|
||||
@@ -93,7 +93,7 @@ const certWithWildcard = await smartAcme.getCertificateForDomain('example.com',
|
||||
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.
|
||||
Certificates are automatically cached and reused when still valid. Renewal happens automatically when a certificate is within 10 days of expiration. The actual X.509 expiry date is parsed from the issued certificate, ensuring renewal timing is precise.
|
||||
|
||||
### 📦 Certificate Object
|
||||
|
||||
@@ -372,10 +372,120 @@ await smartAcme.stop();
|
||||
server.close();
|
||||
```
|
||||
|
||||
## Architecture
|
||||
## 🏗️ ACME Directory Server (Built-in CA)
|
||||
|
||||
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();
|
||||
```
|
||||
|
||||
### 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 the Low-Level ACME Client
|
||||
|
||||
The `SmartAcme` class connects to Let's Encrypt by default. To use a custom ACME directory (like your own server), use the lower-level `AcmeClient` directly:
|
||||
|
||||
```typescript
|
||||
import { server } from '@push.rocks/smartacme';
|
||||
import { AcmeCrypto, AcmeClient } from '@push.rocks/smartacme/ts/acme/index.js';
|
||||
|
||||
// 1. Start your own CA
|
||||
const acmeServer = new server.AcmeServer({
|
||||
port: 14000,
|
||||
challengeVerification: false, // auto-approve for testing
|
||||
});
|
||||
await acmeServer.start();
|
||||
|
||||
// 2. Create an ACME client pointing at your CA
|
||||
const accountKey = AcmeCrypto.createRsaPrivateKey();
|
||||
const client = new AcmeClient({
|
||||
directoryUrl: acmeServer.getDirectoryUrl(),
|
||||
accountKeyPem: accountKey,
|
||||
});
|
||||
|
||||
// 3. Register an account
|
||||
await client.createAccount({ termsOfServiceAgreed: true, contact: ['mailto:admin@internal.example.com'] });
|
||||
|
||||
// 4. Create an order and issue a certificate
|
||||
const order = await client.createOrder({
|
||||
identifiers: [{ type: 'dns', value: 'myapp.internal' }],
|
||||
});
|
||||
|
||||
// ... complete challenges, finalize, and download cert
|
||||
// (challenges auto-approved since challengeVerification is false)
|
||||
|
||||
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 |
|
||||
@@ -386,11 +496,24 @@ Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol i
|
||||
| `AcmeChallengeManager` | Key authorization computation and challenge completion |
|
||||
| `TaskManager` | Constraint-based concurrency control, rate limiting, and request deduplication via `@push.rocks/taskbuffer` |
|
||||
|
||||
All cryptographic operations use `node:crypto`. The only external crypto dependency is `@peculiar/x509` for CSR generation.
|
||||
### 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
|
||||
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license.md) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartacme',
|
||||
version: '9.2.0',
|
||||
description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
|
||||
version: '9.3.1',
|
||||
description: 'A TypeScript-based ACME client and server for certificate management with built-in CA, supporting LetsEncrypt and custom ACME authorities.'
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
import type { IAcmeCsrOptions } from './acme.interfaces.js';
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// reflect-metadata polyfill (required by @peculiar/x509 v2 via tsyringe)
|
||||
import 'reflect-metadata';
|
||||
|
||||
// node native
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export { fs, path };
|
||||
export { crypto, fs, path };
|
||||
|
||||
// @apiclient.xyz scope
|
||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||
|
||||
@@ -85,21 +85,21 @@ export class SmartAcme {
|
||||
private options: ISmartAcmeOptions;
|
||||
|
||||
// the acme client
|
||||
private client: plugins.acme.AcmeClient;
|
||||
private client!: plugins.acme.AcmeClient;
|
||||
private smartdns = new plugins.smartdnsClient.Smartdns({});
|
||||
public logger: plugins.smartlog.Smartlog;
|
||||
|
||||
// the account private key
|
||||
private privateKey: string;
|
||||
private privateKey!: string;
|
||||
|
||||
|
||||
// certificate manager for persistence (implements ICertManager)
|
||||
public certmanager: ICertManager;
|
||||
public certmanager!: ICertManager;
|
||||
// configured pluggable ACME challenge handlers
|
||||
public challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
|
||||
|
||||
|
||||
private certmatcher: SmartacmeCertMatcher;
|
||||
private certmatcher!: SmartacmeCertMatcher;
|
||||
// retry/backoff configuration (resolved with defaults)
|
||||
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
|
||||
// track pending DNS challenges for graceful shutdown
|
||||
@@ -558,6 +558,16 @@ export class SmartAcme {
|
||||
// ── Step: store ───────────────────────────────────────────────────────
|
||||
this.certIssuanceTask.notifyStep('store');
|
||||
|
||||
// Parse real X509 expiry from the issued PEM certificate
|
||||
let validUntil: number;
|
||||
try {
|
||||
const x509 = new plugins.crypto.X509Certificate(cert.toString());
|
||||
validUntil = new Date(x509.validTo).getTime();
|
||||
} catch {
|
||||
// Fallback to 90-day estimate if PEM parsing fails
|
||||
validUntil = Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 });
|
||||
}
|
||||
|
||||
const certRecord = new SmartacmeCert({
|
||||
id: plugins.smartunique.shortId(),
|
||||
domainName: certDomainName,
|
||||
@@ -565,7 +575,7 @@ export class SmartAcme {
|
||||
publicKey: cert.toString(),
|
||||
csr: csr.toString(),
|
||||
created: Date.now(),
|
||||
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }),
|
||||
validUntil,
|
||||
});
|
||||
await this.certmanager.storeCertificate(certRecord);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'reflect-metadata';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,7 @@ export function createAuthzHandler(
|
||||
}
|
||||
|
||||
// Build challenge objects
|
||||
const challenges = [];
|
||||
const challenges: Array<{ type: string; url: string; status: string; token: string; validated?: string }> = [];
|
||||
for (const challengeId of authz.challengeIds) {
|
||||
const challenge = await orderStore.getChallenge(challengeId);
|
||||
if (challenge) {
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"ts/**/*.ts"
|
||||
|
||||
Reference in New Issue
Block a user