fix(acme): parse issued certificate expiry from X.509 metadata and update build compatibility for dependency upgrades

This commit is contained in:
2026-03-27 22:21:16 +00:00
parent ab0ca6ccc3
commit 75def30b0a
15 changed files with 831 additions and 794 deletions

View File

@@ -1,7 +1,7 @@
{ {
"json.schemas": [ "json.schemas": [
{ {
"fileMatch": ["/npmextra.json"], "fileMatch": ["/.smartconfig.json"],
"schema": { "schema": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -1,5 +1,12 @@
# Changelog # 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) ## 2026-03-19 - 9.3.0 - feat(readme)
document built-in ACME directory server and CA capabilities document built-in ACME directory server and CA capabilities

869
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,23 +45,24 @@
"homepage": "https://code.foss.global/push.rocks/smartacme#readme", "homepage": "https://code.foss.global/push.rocks/smartacme#readme",
"dependencies": { "dependencies": {
"@apiclient.xyz/cloudflare": "^7.1.0", "@apiclient.xyz/cloudflare": "^7.1.0",
"@peculiar/x509": "^1.14.3", "@peculiar/x509": "^2.0.0",
"@push.rocks/lik": "^6.3.1", "@push.rocks/lik": "^6.4.0",
"@push.rocks/smartdata": "^7.1.0", "@push.rocks/smartdata": "^7.1.3",
"@push.rocks/smartdelay": "^3.0.5", "@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartdns": "^7.9.0", "@push.rocks/smartdns": "^7.9.0",
"@push.rocks/smartlog": "^3.2.1", "@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/smartstring": "^4.1.0",
"@push.rocks/smarttime": "^4.2.3", "@push.rocks/smarttime": "^4.2.3",
"@push.rocks/smartunique": "^3.0.9", "@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^6.1.2", "@push.rocks/taskbuffer": "^8.0.2",
"@tsclass/tsclass": "^9.5.0" "@tsclass/tsclass": "^9.5.0",
"reflect-metadata": "^0.2.2"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^4.3.0", "@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.1", "@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.4.0", "@git.zone/tstest": "^3.6.3",
"@push.rocks/qenv": "^6.1.3", "@push.rocks/qenv": "^6.1.3",
"@types/node": "^25.5.0" "@types/node": "^25.5.0"
}, },
@@ -74,7 +75,7 @@
"dist_ts_web/**/*", "dist_ts_web/**/*",
"assets/**/*", "assets/**/*",
"cli.js", "cli.js",
"npmextra.json", ".smartconfig.json",
"readme.md" "readme.md"
], ],
"browserslist": [ "browserslist": [

639
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -73,4 +73,9 @@ Design decisions:
- `@push.rocks/smartfile`, `@api.global/typedserver`, `@push.rocks/smartrequest`, `@push.rocks/smartpromise` were removed as unused dependencies in v8.1.0 - `@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. - 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`) - Test imports use `@git.zone/tstest/tapbundle` (not `@push.rocks/tapbundle`)
- Build uses `tsbuild tsfolders` (v4.3.0+) — auto-discovers and compiles `ts/` and `ts_server/` directories - 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).

View File

@@ -93,7 +93,7 @@ const certWithWildcard = await smartAcme.getCertificateForDomain('example.com',
const wildcardCert = 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 ### 📦 Certificate Object
@@ -372,7 +372,7 @@ await smartAcme.stop();
server.close(); server.close();
``` ```
## ACME Directory Server (Built-in CA) ## 🏗️ 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. 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.
@@ -414,36 +414,39 @@ interface IAcmeServerOptions {
} }
``` ```
### Using the Server with SmartAcme Client ### Using the Server with the Low-Level ACME Client
Point the SmartAcme client at your own ACME server for a fully self-contained PKI: 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 ```typescript
import { SmartAcme, certmanagers, handlers, server } from '@push.rocks/smartacme'; import { server } from '@push.rocks/smartacme';
import { AcmeCrypto, AcmeClient } from '@push.rocks/smartacme/ts/acme/index.js';
// 1. Start your own CA // 1. Start your own CA
const acmeServer = new server.AcmeServer({ const acmeServer = new server.AcmeServer({
port: 14000, port: 14000,
challengeVerification: false, challengeVerification: false, // auto-approve for testing
}); });
await acmeServer.start(); await acmeServer.start();
// 2. Set up the client pointing at your CA // 2. Create an ACME client pointing at your CA
const memHandler = new handlers.Http01MemoryHandler(); const accountKey = AcmeCrypto.createRsaPrivateKey();
const smartAcme = new SmartAcme({ const client = new AcmeClient({
accountEmail: 'admin@internal.example.com', directoryUrl: acmeServer.getDirectoryUrl(),
certManager: new certmanagers.MemoryCertManager(), accountKeyPem: accountKey,
environment: 'integration',
challengeHandlers: [memHandler],
directoryUrl: acmeServer.getDirectoryUrl(), // Use your own CA!
}); });
await smartAcme.start(); // 3. Register an account
const cert = await smartAcme.getCertificateForDomain('myapp.internal'); await client.createAccount({ termsOfServiceAgreed: true, contact: ['mailto:admin@internal.example.com'] });
// cert.publicKey — PEM certificate chain signed by your CA
// cert.privateKey — PEM private key // 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 smartAcme.stop();
await acmeServer.stop(); await acmeServer.stop();
``` ```
@@ -477,7 +480,7 @@ fs.writeFileSync('/usr/local/share/ca-certificates/my-ca.crt', acmeServer.getCaC
// Then: sudo update-ca-certificates // Then: sudo update-ca-certificates
``` ```
## Architecture ## 🏛️ Architecture
Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules: Under the hood, SmartAcme uses a fully custom RFC 8555-compliant ACME protocol implementation (no external ACME libraries). Key internal modules:
@@ -510,7 +513,7 @@ All cryptographic operations use `node:crypto`. The only external crypto depende
## License and Legal Information ## 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. **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.

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartacme', name: '@push.rocks/smartacme',
version: '9.3.0', 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.' description: 'A TypeScript-based ACME client and server for certificate management with built-in CA, supporting LetsEncrypt and custom ACME authorities.'
} }

View File

@@ -1,3 +1,4 @@
import 'reflect-metadata';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import type { IAcmeCsrOptions } from './acme.interfaces.js'; import type { IAcmeCsrOptions } from './acme.interfaces.js';

View File

@@ -1,8 +1,12 @@
// reflect-metadata polyfill (required by @peculiar/x509 v2 via tsyringe)
import 'reflect-metadata';
// node native // node native
import * as crypto from 'node:crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
export { fs, path }; export { crypto, fs, path };
// @apiclient.xyz scope // @apiclient.xyz scope
import * as cloudflare from '@apiclient.xyz/cloudflare'; import * as cloudflare from '@apiclient.xyz/cloudflare';

View File

@@ -85,21 +85,21 @@ export class SmartAcme {
private options: ISmartAcmeOptions; private options: ISmartAcmeOptions;
// the acme client // the acme client
private client: plugins.acme.AcmeClient; private client!: plugins.acme.AcmeClient;
private smartdns = new plugins.smartdnsClient.Smartdns({}); private smartdns = new plugins.smartdnsClient.Smartdns({});
public logger: plugins.smartlog.Smartlog; public logger: plugins.smartlog.Smartlog;
// the account private key // the account private key
private privateKey: string; private privateKey!: string;
// certificate manager for persistence (implements ICertManager) // certificate manager for persistence (implements ICertManager)
public certmanager: ICertManager; public certmanager!: ICertManager;
// configured pluggable ACME challenge handlers // configured pluggable ACME challenge handlers
public challengeHandlers: plugins.handlers.IChallengeHandler<any>[]; public challengeHandlers: plugins.handlers.IChallengeHandler<any>[];
private certmatcher: SmartacmeCertMatcher; private certmatcher!: SmartacmeCertMatcher;
// retry/backoff configuration (resolved with defaults) // retry/backoff configuration (resolved with defaults)
private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number }; private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
// track pending DNS challenges for graceful shutdown // track pending DNS challenges for graceful shutdown
@@ -558,6 +558,16 @@ export class SmartAcme {
// ── Step: store ─────────────────────────────────────────────────────── // ── Step: store ───────────────────────────────────────────────────────
this.certIssuanceTask.notifyStep('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({ const certRecord = new SmartacmeCert({
id: plugins.smartunique.shortId(), id: plugins.smartunique.shortId(),
domainName: certDomainName, domainName: certDomainName,
@@ -565,7 +575,7 @@ export class SmartAcme {
publicKey: cert.toString(), publicKey: cert.toString(),
csr: csr.toString(), csr: csr.toString(),
created: Date.now(), created: Date.now(),
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ days: 90 }), validUntil,
}); });
await this.certmanager.storeCertificate(certRecord); await this.certmanager.storeCertificate(certRecord);

View File

@@ -1,3 +1,4 @@
import 'reflect-metadata';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
/** /**

View File

@@ -27,7 +27,7 @@ export function createAuthzHandler(
} }
// Build challenge objects // Build challenge objects
const challenges = []; const challenges: Array<{ type: string; url: string; status: string; token: string; validated?: string }> = [];
for (const challengeId of authz.challengeIds) { for (const challengeId of authz.challengeIds) {
const challenge = await orderStore.getChallenge(challengeId); const challenge = await orderStore.getChallenge(challengeId);
if (challenge) { if (challenge) {

View File

@@ -8,8 +8,7 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"baseUrl": ".", "types": ["node"]
"paths": {}
}, },
"include": [ "include": [
"ts/**/*.ts" "ts/**/*.ts"