@push.rocks/smartacme 9.1.3 → 9.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/acme/acme.classes.account.js +1 -1
- package/dist_ts/acme/acme.classes.challenge.js +1 -1
- package/dist_ts/acme/acme.classes.client.js +1 -1
- package/dist_ts/acme/acme.classes.crypto.d.ts +1 -1
- package/dist_ts/acme/acme.classes.crypto.js +1 -1
- package/dist_ts/acme/acme.classes.directory.js +1 -1
- package/dist_ts/acme/acme.classes.error.js +1 -1
- package/dist_ts/acme/acme.classes.http-client.js +1 -1
- package/dist_ts/acme/acme.classes.order.js +1 -1
- package/dist_ts/acme/acme.interfaces.js +1 -1
- package/dist_ts/acme/index.js +1 -1
- package/dist_ts/certmanagers/index.js +1 -1
- package/dist_ts/certmanagers/memory.js +1 -1
- package/dist_ts/certmanagers/mongo.js +1 -1
- package/dist_ts/handlers/Dns01Handler.js +1 -1
- package/dist_ts/handlers/Http01Handler.js +1 -1
- package/dist_ts/handlers/Http01MemoryHandler.js +1 -1
- package/dist_ts/handlers/IChallengeHandler.js +1 -1
- package/dist_ts/handlers/index.js +1 -1
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/interfaces/accountdata.js +1 -1
- package/dist_ts/interfaces/certmanager.js +1 -1
- package/dist_ts/interfaces/index.js +1 -1
- package/dist_ts/plugins.js +1 -1
- package/dist_ts/smartacme.classes.cert.js +1 -1
- package/dist_ts/smartacme.classes.certmatcher.js +1 -1
- package/dist_ts/smartacme.classes.smartacme.js +1 -1
- package/dist_ts_server/index.d.ts +4 -0
- package/dist_ts_server/index.js +4 -0
- package/dist_ts_server/server.classes.account.store.d.ts +12 -0
- package/dist_ts_server/server.classes.account.store.js +25 -0
- package/dist_ts_server/server.classes.acmeserver.d.ts +22 -0
- package/dist_ts_server/server.classes.acmeserver.js +100 -0
- package/dist_ts_server/server.classes.ca.d.ts +25 -0
- package/dist_ts_server/server.classes.ca.js +109 -0
- package/dist_ts_server/server.classes.challenge.verifier.d.ts +16 -0
- package/dist_ts_server/server.classes.challenge.verifier.js +56 -0
- package/dist_ts_server/server.classes.jws.verifier.d.ts +31 -0
- package/dist_ts_server/server.classes.jws.verifier.js +116 -0
- package/dist_ts_server/server.classes.nonce.d.ts +12 -0
- package/dist_ts_server/server.classes.nonce.js +32 -0
- package/dist_ts_server/server.classes.order.store.d.ts +21 -0
- package/dist_ts_server/server.classes.order.store.js +57 -0
- package/dist_ts_server/server.classes.router.d.ts +17 -0
- package/dist_ts_server/server.classes.router.js +99 -0
- package/dist_ts_server/server.handlers.account.d.ts +8 -0
- package/dist_ts_server/server.handlers.account.js +59 -0
- package/dist_ts_server/server.handlers.authz.d.ts +7 -0
- package/dist_ts_server/server.handlers.authz.js +41 -0
- package/dist_ts_server/server.handlers.cert.d.ts +7 -0
- package/dist_ts_server/server.handlers.cert.js +18 -0
- package/dist_ts_server/server.handlers.challenge.d.ts +10 -0
- package/dist_ts_server/server.handlers.challenge.js +110 -0
- package/dist_ts_server/server.handlers.directory.d.ts +5 -0
- package/dist_ts_server/server.handlers.directory.js +23 -0
- package/dist_ts_server/server.handlers.finalize.d.ts +8 -0
- package/dist_ts_server/server.handlers.finalize.js +65 -0
- package/dist_ts_server/server.handlers.nonce.d.ts +7 -0
- package/dist_ts_server/server.handlers.nonce.js +17 -0
- package/dist_ts_server/server.handlers.order.d.ts +11 -0
- package/dist_ts_server/server.handlers.order.js +131 -0
- package/dist_ts_server/server.interfaces.d.ts +72 -0
- package/dist_ts_server/server.interfaces.js +2 -0
- package/package.json +18 -13
- package/readme.hints.md +25 -1
- package/readme.md +122 -2
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/acme/acme.classes.crypto.ts +1 -1
- package/ts/index.ts +4 -0
- package/ts_server/index.ts +12 -0
- package/ts_server/server.classes.account.store.ts +27 -0
- package/ts_server/server.classes.acmeserver.ts +128 -0
- package/ts_server/server.classes.ca.ts +142 -0
- package/ts_server/server.classes.challenge.verifier.ts +61 -0
- package/ts_server/server.classes.jws.verifier.ts +153 -0
- package/ts_server/server.classes.nonce.ts +36 -0
- package/ts_server/server.classes.order.store.ts +72 -0
- package/ts_server/server.classes.router.ts +116 -0
- package/ts_server/server.handlers.account.ts +85 -0
- package/ts_server/server.handlers.authz.ts +58 -0
- package/ts_server/server.handlers.cert.ts +32 -0
- package/ts_server/server.handlers.challenge.ts +142 -0
- package/ts_server/server.handlers.directory.ts +30 -0
- package/ts_server/server.handlers.finalize.ts +93 -0
- package/ts_server/server.handlers.nonce.ts +29 -0
- package/ts_server/server.handlers.order.ts +177 -0
- package/ts_server/server.interfaces.ts +98 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import type { IAcmeServerOptions } from './server.interfaces.js';
|
|
3
|
+
import { NonceManager } from './server.classes.nonce.js';
|
|
4
|
+
import { JwsVerifier } from './server.classes.jws.verifier.js';
|
|
5
|
+
import { MemoryAccountStore } from './server.classes.account.store.js';
|
|
6
|
+
import { MemoryOrderStore } from './server.classes.order.store.js';
|
|
7
|
+
import { AcmeServerCA } from './server.classes.ca.js';
|
|
8
|
+
import { ChallengeVerifier } from './server.classes.challenge.verifier.js';
|
|
9
|
+
import { AcmeRouter } from './server.classes.router.js';
|
|
10
|
+
import { createDirectoryHandler } from './server.handlers.directory.js';
|
|
11
|
+
import { createNonceHeadHandler, createNonceGetHandler } from './server.handlers.nonce.js';
|
|
12
|
+
import { createAccountHandler } from './server.handlers.account.js';
|
|
13
|
+
import { createNewOrderHandler, createOrderPollHandler } from './server.handlers.order.js';
|
|
14
|
+
import { createAuthzHandler } from './server.handlers.authz.js';
|
|
15
|
+
import { createChallengeHandler } from './server.handlers.challenge.js';
|
|
16
|
+
import { createFinalizeHandler } from './server.handlers.finalize.js';
|
|
17
|
+
import { createCertHandler } from './server.handlers.cert.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ACME Directory Server — a self-contained RFC 8555 Certificate Authority.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* ```ts
|
|
24
|
+
* const server = new AcmeServer({ port: 14000 });
|
|
25
|
+
* await server.start();
|
|
26
|
+
* console.log(server.getDirectoryUrl()); // http://localhost:14000/directory
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export class AcmeServer {
|
|
30
|
+
private options: Required<Pick<IAcmeServerOptions, 'port' | 'hostname'>> & IAcmeServerOptions;
|
|
31
|
+
private httpServer: http.Server | null = null;
|
|
32
|
+
private ca: AcmeServerCA;
|
|
33
|
+
private baseUrl: string;
|
|
34
|
+
|
|
35
|
+
constructor(options: IAcmeServerOptions = {}) {
|
|
36
|
+
this.options = {
|
|
37
|
+
port: options.port ?? 14000,
|
|
38
|
+
hostname: options.hostname ?? '0.0.0.0',
|
|
39
|
+
...options,
|
|
40
|
+
};
|
|
41
|
+
this.baseUrl = options.baseUrl ?? `http://localhost:${this.options.port}`;
|
|
42
|
+
this.ca = new AcmeServerCA(options.caOptions);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async start(): Promise<void> {
|
|
46
|
+
// Initialize CA
|
|
47
|
+
await this.ca.init();
|
|
48
|
+
|
|
49
|
+
// Create stores
|
|
50
|
+
const accountStore = new MemoryAccountStore();
|
|
51
|
+
const orderStore = new MemoryOrderStore();
|
|
52
|
+
|
|
53
|
+
// Create managers
|
|
54
|
+
const nonceManager = new NonceManager();
|
|
55
|
+
const jwsVerifier = new JwsVerifier(nonceManager, accountStore);
|
|
56
|
+
const challengeVerifier = new ChallengeVerifier(this.options.challengeVerification ?? true);
|
|
57
|
+
|
|
58
|
+
// Create router and register routes
|
|
59
|
+
const router = new AcmeRouter(nonceManager);
|
|
60
|
+
|
|
61
|
+
// Directory
|
|
62
|
+
router.addRoute('GET', '/directory', createDirectoryHandler(this.baseUrl));
|
|
63
|
+
|
|
64
|
+
// Nonce
|
|
65
|
+
router.addRoute('HEAD', '/new-nonce', createNonceHeadHandler());
|
|
66
|
+
router.addRoute('GET', '/new-nonce', createNonceGetHandler());
|
|
67
|
+
|
|
68
|
+
// Account
|
|
69
|
+
router.addRoute('POST', '/new-account', createAccountHandler(this.baseUrl, jwsVerifier, accountStore));
|
|
70
|
+
|
|
71
|
+
// Order
|
|
72
|
+
router.addRoute('POST', '/new-order', createNewOrderHandler(this.baseUrl, jwsVerifier, orderStore));
|
|
73
|
+
router.addRoute('POST', '/order/:id', createOrderPollHandler(this.baseUrl, jwsVerifier, orderStore));
|
|
74
|
+
|
|
75
|
+
// Authorization
|
|
76
|
+
router.addRoute('POST', '/authz/:id', createAuthzHandler(this.baseUrl, jwsVerifier, orderStore));
|
|
77
|
+
|
|
78
|
+
// Challenge
|
|
79
|
+
router.addRoute('POST', '/challenge/:id', createChallengeHandler(
|
|
80
|
+
this.baseUrl,
|
|
81
|
+
jwsVerifier,
|
|
82
|
+
orderStore,
|
|
83
|
+
accountStore,
|
|
84
|
+
challengeVerifier,
|
|
85
|
+
));
|
|
86
|
+
|
|
87
|
+
// Finalize
|
|
88
|
+
router.addRoute('POST', '/finalize/:id', createFinalizeHandler(this.baseUrl, jwsVerifier, orderStore, this.ca));
|
|
89
|
+
|
|
90
|
+
// Certificate
|
|
91
|
+
router.addRoute('POST', '/cert/:id', createCertHandler(this.baseUrl, jwsVerifier, orderStore));
|
|
92
|
+
|
|
93
|
+
// Start HTTP server
|
|
94
|
+
this.httpServer = http.createServer((req, res) => {
|
|
95
|
+
router.handle(req, res).catch((err) => {
|
|
96
|
+
if (!res.headersSent) {
|
|
97
|
+
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
|
|
98
|
+
res.end(JSON.stringify({
|
|
99
|
+
type: 'urn:ietf:params:acme:error:serverInternal',
|
|
100
|
+
detail: err instanceof Error ? err.message : 'Unknown error',
|
|
101
|
+
status: 500,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await new Promise<void>((resolve) => {
|
|
108
|
+
this.httpServer!.listen(this.options.port, this.options.hostname, () => resolve());
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async stop(): Promise<void> {
|
|
113
|
+
if (this.httpServer) {
|
|
114
|
+
await new Promise<void>((resolve, reject) => {
|
|
115
|
+
this.httpServer!.close((err) => (err ? reject(err) : resolve()));
|
|
116
|
+
});
|
|
117
|
+
this.httpServer = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getDirectoryUrl(): string {
|
|
122
|
+
return `${this.baseUrl}/directory`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getCaCertPem(): string {
|
|
126
|
+
return this.ca.getCaCertPem();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Certificate Authority for the ACME server.
|
|
5
|
+
* Generates a self-signed root CA and signs certificates from CSRs.
|
|
6
|
+
* Uses @peculiar/x509 (already a project dependency).
|
|
7
|
+
*/
|
|
8
|
+
export class AcmeServerCA {
|
|
9
|
+
private caKeyPair!: CryptoKeyPair;
|
|
10
|
+
private caCert!: InstanceType<typeof import('@peculiar/x509').X509Certificate>;
|
|
11
|
+
private caCertPem!: string;
|
|
12
|
+
private certValidityDays: number;
|
|
13
|
+
|
|
14
|
+
constructor(private options: { commonName?: string; validityDays?: number; certValidityDays?: number } = {}) {
|
|
15
|
+
this.certValidityDays = options.certValidityDays ?? 90;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async init(): Promise<void> {
|
|
19
|
+
const x509 = await import('@peculiar/x509');
|
|
20
|
+
const { webcrypto } = crypto;
|
|
21
|
+
x509.cryptoProvider.set(webcrypto as any);
|
|
22
|
+
|
|
23
|
+
// Generate RSA key pair for the CA
|
|
24
|
+
this.caKeyPair = await webcrypto.subtle.generateKey(
|
|
25
|
+
{
|
|
26
|
+
name: 'RSASSA-PKCS1-v1_5',
|
|
27
|
+
modulusLength: 2048,
|
|
28
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
29
|
+
hash: 'SHA-256',
|
|
30
|
+
},
|
|
31
|
+
true,
|
|
32
|
+
['sign', 'verify'],
|
|
33
|
+
) as CryptoKeyPair;
|
|
34
|
+
|
|
35
|
+
const cn = this.options.commonName ?? 'SmartACME Test CA';
|
|
36
|
+
const validityDays = this.options.validityDays ?? 3650;
|
|
37
|
+
const notBefore = new Date();
|
|
38
|
+
const notAfter = new Date();
|
|
39
|
+
notAfter.setDate(notAfter.getDate() + validityDays);
|
|
40
|
+
|
|
41
|
+
// Create self-signed root CA certificate
|
|
42
|
+
this.caCert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
43
|
+
serialNumber: this.randomSerialNumber(),
|
|
44
|
+
name: `CN=${cn}`,
|
|
45
|
+
notBefore,
|
|
46
|
+
notAfter,
|
|
47
|
+
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
|
|
48
|
+
keys: this.caKeyPair,
|
|
49
|
+
extensions: [
|
|
50
|
+
new x509.BasicConstraintsExtension(true, undefined, true),
|
|
51
|
+
new x509.KeyUsagesExtension(
|
|
52
|
+
x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign,
|
|
53
|
+
true,
|
|
54
|
+
),
|
|
55
|
+
await x509.SubjectKeyIdentifierExtension.create(this.caKeyPair.publicKey),
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
this.caCertPem = this.caCert.toString('pem');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sign a CSR and return a PEM certificate chain (end-entity + root CA).
|
|
64
|
+
* @param csrDerBase64url - The CSR in base64url-encoded DER format (as sent by ACME clients)
|
|
65
|
+
*/
|
|
66
|
+
async signCsr(csrDerBase64url: string): Promise<string> {
|
|
67
|
+
const x509 = await import('@peculiar/x509');
|
|
68
|
+
const { webcrypto } = crypto;
|
|
69
|
+
x509.cryptoProvider.set(webcrypto as any);
|
|
70
|
+
|
|
71
|
+
// Parse the CSR
|
|
72
|
+
const csrDer = Buffer.from(csrDerBase64url, 'base64url');
|
|
73
|
+
const csr = new x509.Pkcs10CertificateRequest(csrDer);
|
|
74
|
+
|
|
75
|
+
// Extract Subject Alternative Names from CSR extensions
|
|
76
|
+
const sanNames: { type: 'dns'; value: string }[] = [];
|
|
77
|
+
const sanExt = csr.extensions?.find(
|
|
78
|
+
(ext) => ext.type === '2.5.29.17', // OID for SubjectAlternativeName
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (sanExt) {
|
|
82
|
+
const san = new x509.SubjectAlternativeNameExtension(sanExt.rawData);
|
|
83
|
+
if (san.names) {
|
|
84
|
+
const jsonNames = san.names.toJSON();
|
|
85
|
+
for (const name of jsonNames) {
|
|
86
|
+
if (name.type === 'dns') {
|
|
87
|
+
sanNames.push({ type: 'dns', value: name.value });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If no SAN found, use CN from subject
|
|
94
|
+
if (sanNames.length === 0) {
|
|
95
|
+
const cnMatch = csr.subject.match(/CN=([^,]+)/);
|
|
96
|
+
if (cnMatch) {
|
|
97
|
+
sanNames.push({ type: 'dns', value: cnMatch[1] });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const notBefore = new Date();
|
|
102
|
+
const notAfter = new Date();
|
|
103
|
+
notAfter.setDate(notAfter.getDate() + this.certValidityDays);
|
|
104
|
+
|
|
105
|
+
// Sign the certificate
|
|
106
|
+
const cert = await x509.X509CertificateGenerator.create({
|
|
107
|
+
serialNumber: this.randomSerialNumber(),
|
|
108
|
+
subject: csr.subject,
|
|
109
|
+
issuer: this.caCert.subject,
|
|
110
|
+
notBefore,
|
|
111
|
+
notAfter,
|
|
112
|
+
signingAlgorithm: { name: 'RSASSA-PKCS1-v1_5' },
|
|
113
|
+
publicKey: csr.publicKey,
|
|
114
|
+
signingKey: this.caKeyPair.privateKey,
|
|
115
|
+
extensions: [
|
|
116
|
+
new x509.BasicConstraintsExtension(false, undefined, true),
|
|
117
|
+
new x509.KeyUsagesExtension(
|
|
118
|
+
x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
|
|
119
|
+
true,
|
|
120
|
+
),
|
|
121
|
+
new x509.ExtendedKeyUsageExtension(
|
|
122
|
+
['1.3.6.1.5.5.7.3.1'], // serverAuth
|
|
123
|
+
true,
|
|
124
|
+
),
|
|
125
|
+
new x509.SubjectAlternativeNameExtension(sanNames),
|
|
126
|
+
await x509.AuthorityKeyIdentifierExtension.create(this.caKeyPair.publicKey),
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Return PEM chain: end-entity cert + root CA cert
|
|
131
|
+
const certPem = cert.toString('pem');
|
|
132
|
+
return `${certPem}\n${this.caCertPem}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getCaCertPem(): string {
|
|
136
|
+
return this.caCertPem;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private randomSerialNumber(): string {
|
|
140
|
+
return crypto.randomBytes(16).toString('hex');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verifies ACME challenges by making HTTP requests or DNS lookups.
|
|
5
|
+
*/
|
|
6
|
+
export class ChallengeVerifier {
|
|
7
|
+
private verificationEnabled: boolean;
|
|
8
|
+
|
|
9
|
+
constructor(verificationEnabled = true) {
|
|
10
|
+
this.verificationEnabled = verificationEnabled;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verify an HTTP-01 challenge by fetching the token from the domain.
|
|
15
|
+
*/
|
|
16
|
+
async verifyHttp01(domain: string, token: string, expectedKeyAuth: string): Promise<boolean> {
|
|
17
|
+
if (!this.verificationEnabled) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const url = `http://${domain}/.well-known/acme-challenge/${token}`;
|
|
23
|
+
const body = await this.httpGet(url);
|
|
24
|
+
return body.trim() === expectedKeyAuth.trim();
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Verify a DNS-01 challenge by looking up the TXT record.
|
|
32
|
+
*/
|
|
33
|
+
async verifyDns01(domain: string, expectedHash: string): Promise<boolean> {
|
|
34
|
+
if (!this.verificationEnabled) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { promises: dns } = await import('node:dns');
|
|
40
|
+
const records = await dns.resolveTxt(`_acme-challenge.${domain}`);
|
|
41
|
+
const flatRecords = records.map((r) => r.join(''));
|
|
42
|
+
return flatRecords.some((r) => r === expectedHash);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private httpGet(url: string): Promise<string> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const req = http.get(url, { timeout: 10000 }, (res) => {
|
|
51
|
+
const chunks: Buffer[] = [];
|
|
52
|
+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
53
|
+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
54
|
+
});
|
|
55
|
+
req.on('error', reject);
|
|
56
|
+
req.on('timeout', () => {
|
|
57
|
+
req.destroy(new Error('HTTP-01 verification timeout'));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
|
3
|
+
import type { NonceManager } from './server.classes.nonce.js';
|
|
4
|
+
import type { IServerAccountStore } from './server.interfaces.js';
|
|
5
|
+
|
|
6
|
+
export interface IJwsVerifyResult {
|
|
7
|
+
jwk: Record<string, string>;
|
|
8
|
+
kid: string | null;
|
|
9
|
+
thumbprint: string;
|
|
10
|
+
payload: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verifies JWS-signed ACME requests.
|
|
15
|
+
* This is the inverse of AcmeCrypto.createJws().
|
|
16
|
+
*/
|
|
17
|
+
export class JwsVerifier {
|
|
18
|
+
private nonceManager: NonceManager;
|
|
19
|
+
private accountStore: IServerAccountStore;
|
|
20
|
+
|
|
21
|
+
constructor(nonceManager: NonceManager, accountStore: IServerAccountStore) {
|
|
22
|
+
this.nonceManager = nonceManager;
|
|
23
|
+
this.accountStore = accountStore;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async verify(
|
|
27
|
+
body: { protected: string; payload: string; signature: string },
|
|
28
|
+
expectedUrl: string,
|
|
29
|
+
): Promise<IJwsVerifyResult> {
|
|
30
|
+
if (!body || !body.protected || body.signature === undefined) {
|
|
31
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS structure');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 1. Decode protected header
|
|
35
|
+
const headerJson = Buffer.from(body.protected, 'base64url').toString('utf-8');
|
|
36
|
+
let header: Record<string, any>;
|
|
37
|
+
try {
|
|
38
|
+
header = JSON.parse(headerJson);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS protected header');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Validate required fields
|
|
44
|
+
const { alg, nonce, url, jwk, kid } = header;
|
|
45
|
+
if (!alg) {
|
|
46
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing alg in protected header');
|
|
47
|
+
}
|
|
48
|
+
if (!nonce) {
|
|
49
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Missing nonce in protected header');
|
|
50
|
+
}
|
|
51
|
+
if (!url) {
|
|
52
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Missing url in protected header');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Validate URL matches
|
|
56
|
+
if (url !== expectedUrl) {
|
|
57
|
+
throw new AcmeServerError(
|
|
58
|
+
400,
|
|
59
|
+
'urn:ietf:params:acme:error:malformed',
|
|
60
|
+
`URL mismatch: expected ${expectedUrl}, got ${url}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Validate and consume nonce
|
|
65
|
+
if (!this.nonceManager.consume(nonce)) {
|
|
66
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badNonce', 'Invalid or expired nonce');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 5. Must have exactly one of jwk or kid
|
|
70
|
+
if (jwk && kid) {
|
|
71
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid, not both');
|
|
72
|
+
}
|
|
73
|
+
if (!jwk && !kid) {
|
|
74
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'JWS must have jwk or kid');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 6. Resolve the public key
|
|
78
|
+
let resolvedJwk: Record<string, string>;
|
|
79
|
+
if (jwk) {
|
|
80
|
+
resolvedJwk = jwk;
|
|
81
|
+
} else {
|
|
82
|
+
// Look up account by kid (account URL)
|
|
83
|
+
const account = await this.accountStore.getByUrl(kid);
|
|
84
|
+
if (!account) {
|
|
85
|
+
throw new AcmeServerError(
|
|
86
|
+
400,
|
|
87
|
+
'urn:ietf:params:acme:error:accountDoesNotExist',
|
|
88
|
+
'Account not found for kid',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
resolvedJwk = account.jwk;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 7. Reconstruct public key and verify signature
|
|
95
|
+
const publicKey = crypto.createPublicKey({ key: resolvedJwk, format: 'jwk' });
|
|
96
|
+
const signingInput = `${body.protected}.${body.payload}`;
|
|
97
|
+
const signatureBuffer = Buffer.from(body.signature, 'base64url');
|
|
98
|
+
|
|
99
|
+
const supportedAlgs = ['RS256', 'ES256', 'ES384'];
|
|
100
|
+
if (!supportedAlgs.includes(alg)) {
|
|
101
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:badSignatureAlgorithm', `Unsupported algorithm: ${alg}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let valid: boolean;
|
|
105
|
+
if (alg.startsWith('RS')) {
|
|
106
|
+
valid = crypto.verify('sha256', Buffer.from(signingInput), publicKey, signatureBuffer);
|
|
107
|
+
} else {
|
|
108
|
+
valid = crypto.verify('sha256', Buffer.from(signingInput), { key: publicKey, dsaEncoding: 'ieee-p1363' }, signatureBuffer);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!valid) {
|
|
112
|
+
throw new AcmeServerError(403, 'urn:ietf:params:acme:error:unauthorized', 'Invalid JWS signature');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 8. Decode payload
|
|
116
|
+
let payload: any;
|
|
117
|
+
if (body.payload === '') {
|
|
118
|
+
payload = null; // POST-as-GET
|
|
119
|
+
} else {
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(Buffer.from(body.payload, 'base64url').toString('utf-8'));
|
|
122
|
+
} catch {
|
|
123
|
+
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JWS payload');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const thumbprint = AcmeCrypto.getJwkThumbprint(resolvedJwk);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
jwk: resolvedJwk,
|
|
131
|
+
kid: kid || null,
|
|
132
|
+
thumbprint,
|
|
133
|
+
payload,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Simple error class for ACME server errors that maps to RFC 8555 problem responses.
|
|
140
|
+
*/
|
|
141
|
+
export class AcmeServerError extends Error {
|
|
142
|
+
public readonly status: number;
|
|
143
|
+
public readonly type: string;
|
|
144
|
+
public readonly detail: string;
|
|
145
|
+
|
|
146
|
+
constructor(status: number, type: string, detail: string) {
|
|
147
|
+
super(`${type}: ${detail}`);
|
|
148
|
+
this.name = 'AcmeServerError';
|
|
149
|
+
this.status = status;
|
|
150
|
+
this.type = type;
|
|
151
|
+
this.detail = detail;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Manages ACME replay nonces.
|
|
5
|
+
* Each nonce is single-use: consumed on verification, fresh one issued with every response.
|
|
6
|
+
*/
|
|
7
|
+
export class NonceManager {
|
|
8
|
+
private nonces = new Set<string>();
|
|
9
|
+
private nonceQueue: string[] = [];
|
|
10
|
+
private maxSize: number;
|
|
11
|
+
|
|
12
|
+
constructor(maxSize = 10000) {
|
|
13
|
+
this.maxSize = maxSize;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
generate(): string {
|
|
17
|
+
const nonce = crypto.randomBytes(16).toString('base64url');
|
|
18
|
+
if (this.nonces.size >= this.maxSize) {
|
|
19
|
+
const oldest = this.nonceQueue.shift();
|
|
20
|
+
if (oldest) {
|
|
21
|
+
this.nonces.delete(oldest);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
this.nonces.add(nonce);
|
|
25
|
+
this.nonceQueue.push(nonce);
|
|
26
|
+
return nonce;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
consume(nonce: string): boolean {
|
|
30
|
+
if (this.nonces.has(nonce)) {
|
|
31
|
+
this.nonces.delete(nonce);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IServerOrderStore,
|
|
3
|
+
IServerOrder,
|
|
4
|
+
IServerAuthorization,
|
|
5
|
+
IServerChallenge,
|
|
6
|
+
} from './server.interfaces.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory order/authorization/challenge/certificate storage for the ACME server.
|
|
10
|
+
*/
|
|
11
|
+
export class MemoryOrderStore implements IServerOrderStore {
|
|
12
|
+
private orders = new Map<string, IServerOrder>();
|
|
13
|
+
private authorizations = new Map<string, IServerAuthorization>();
|
|
14
|
+
private challenges = new Map<string, IServerChallenge>();
|
|
15
|
+
private certPems = new Map<string, string>();
|
|
16
|
+
|
|
17
|
+
async createOrder(order: IServerOrder): Promise<IServerOrder> {
|
|
18
|
+
this.orders.set(order.id, order);
|
|
19
|
+
return order;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async getOrder(id: string): Promise<IServerOrder | null> {
|
|
23
|
+
return this.orders.get(id) || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async updateOrder(id: string, updates: Partial<IServerOrder>): Promise<void> {
|
|
27
|
+
const order = this.orders.get(id);
|
|
28
|
+
if (order) {
|
|
29
|
+
Object.assign(order, updates);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async createAuthorization(authz: IServerAuthorization): Promise<IServerAuthorization> {
|
|
34
|
+
this.authorizations.set(authz.id, authz);
|
|
35
|
+
return authz;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getAuthorization(id: string): Promise<IServerAuthorization | null> {
|
|
39
|
+
return this.authorizations.get(id) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async updateAuthorization(id: string, updates: Partial<IServerAuthorization>): Promise<void> {
|
|
43
|
+
const authz = this.authorizations.get(id);
|
|
44
|
+
if (authz) {
|
|
45
|
+
Object.assign(authz, updates);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async createChallenge(challenge: IServerChallenge): Promise<IServerChallenge> {
|
|
50
|
+
this.challenges.set(challenge.id, challenge);
|
|
51
|
+
return challenge;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async getChallenge(id: string): Promise<IServerChallenge | null> {
|
|
55
|
+
return this.challenges.get(id) || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async updateChallenge(id: string, updates: Partial<IServerChallenge>): Promise<void> {
|
|
59
|
+
const challenge = this.challenges.get(id);
|
|
60
|
+
if (challenge) {
|
|
61
|
+
Object.assign(challenge, updates);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async storeCertPem(orderId: string, pem: string): Promise<void> {
|
|
66
|
+
this.certPems.set(orderId, pem);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getCertPem(orderId: string): Promise<string | null> {
|
|
70
|
+
return this.certPems.get(orderId) || null;
|
|
71
|
+
}
|
|
72
|
+
}
|