@push.rocks/smartacme 9.1.2 → 9.2.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 +2 -2
- 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 +2 -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 +11 -11
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme.classes.crypto.ts +1 -1
- package/ts/index.ts +4 -0
- package/ts/smartacme.classes.smartacme.ts +1 -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,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
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type * as http from 'node:http';
|
|
2
|
+
import type { TRouteHandler } from './server.interfaces.js';
|
|
3
|
+
import type { NonceManager } from './server.classes.nonce.js';
|
|
4
|
+
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
|
5
|
+
|
|
6
|
+
interface IRoute {
|
|
7
|
+
method: string;
|
|
8
|
+
pattern: string;
|
|
9
|
+
segments: string[];
|
|
10
|
+
handler: TRouteHandler;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimal HTTP router for the ACME server.
|
|
15
|
+
* Supports parameterized paths like /order/:id.
|
|
16
|
+
*/
|
|
17
|
+
export class AcmeRouter {
|
|
18
|
+
private routes: IRoute[] = [];
|
|
19
|
+
private nonceManager: NonceManager;
|
|
20
|
+
|
|
21
|
+
constructor(nonceManager: NonceManager) {
|
|
22
|
+
this.nonceManager = nonceManager;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
addRoute(method: string, pattern: string, handler: TRouteHandler): void {
|
|
26
|
+
this.routes.push({
|
|
27
|
+
method: method.toUpperCase(),
|
|
28
|
+
pattern,
|
|
29
|
+
segments: pattern.split('/').filter(Boolean),
|
|
30
|
+
handler,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
35
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
36
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
37
|
+
const pathSegments = url.pathname.split('/').filter(Boolean);
|
|
38
|
+
|
|
39
|
+
// Always add a fresh nonce to every response
|
|
40
|
+
res.setHeader('Replay-Nonce', this.nonceManager.generate());
|
|
41
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
42
|
+
|
|
43
|
+
// Find matching route
|
|
44
|
+
for (const route of this.routes) {
|
|
45
|
+
if (route.method !== method) continue;
|
|
46
|
+
const params = this.matchPath(route.segments, pathSegments);
|
|
47
|
+
if (params === null) continue;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const body = method === 'POST' ? await this.parseBody(req) : undefined;
|
|
51
|
+
await route.handler(req, res, params, body);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
this.sendError(res, err);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// No route found
|
|
59
|
+
this.sendError(res, new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Not found'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private matchPath(
|
|
63
|
+
routeSegments: string[],
|
|
64
|
+
pathSegments: string[],
|
|
65
|
+
): Record<string, string> | null {
|
|
66
|
+
if (routeSegments.length !== pathSegments.length) return null;
|
|
67
|
+
const params: Record<string, string> = {};
|
|
68
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
|
69
|
+
if (routeSegments[i].startsWith(':')) {
|
|
70
|
+
params[routeSegments[i].slice(1)] = pathSegments[i];
|
|
71
|
+
} else if (routeSegments[i] !== pathSegments[i]) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return params;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private parseBody(req: http.IncomingMessage): Promise<any> {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const chunks: Buffer[] = [];
|
|
81
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
82
|
+
req.on('end', () => {
|
|
83
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
84
|
+
if (!raw) {
|
|
85
|
+
resolve(undefined);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
resolve(JSON.parse(raw));
|
|
90
|
+
} catch {
|
|
91
|
+
reject(new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'Invalid JSON body'));
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private sendError(res: http.ServerResponse, err: unknown): void {
|
|
99
|
+
if (err instanceof AcmeServerError) {
|
|
100
|
+
res.writeHead(err.status, { 'Content-Type': 'application/problem+json' });
|
|
101
|
+
res.end(JSON.stringify({
|
|
102
|
+
type: err.type,
|
|
103
|
+
detail: err.detail,
|
|
104
|
+
status: err.status,
|
|
105
|
+
}));
|
|
106
|
+
} else {
|
|
107
|
+
const message = err instanceof Error ? err.message : 'Internal server error';
|
|
108
|
+
res.writeHead(500, { 'Content-Type': 'application/problem+json' });
|
|
109
|
+
res.end(JSON.stringify({
|
|
110
|
+
type: 'urn:ietf:params:acme:error:serverInternal',
|
|
111
|
+
detail: message,
|
|
112
|
+
status: 500,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type * as http from 'node:http';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import { AcmeCrypto } from '../ts/acme/acme.classes.crypto.js';
|
|
4
|
+
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
|
5
|
+
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
|
6
|
+
import type { IServerAccountStore } from './server.interfaces.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /new-account — Register or retrieve an ACME account.
|
|
10
|
+
* Expects JWS with JWK in protected header (not kid).
|
|
11
|
+
*/
|
|
12
|
+
export function createAccountHandler(
|
|
13
|
+
baseUrl: string,
|
|
14
|
+
jwsVerifier: JwsVerifier,
|
|
15
|
+
accountStore: IServerAccountStore,
|
|
16
|
+
) {
|
|
17
|
+
return async (
|
|
18
|
+
req: http.IncomingMessage,
|
|
19
|
+
res: http.ServerResponse,
|
|
20
|
+
_params: Record<string, string>,
|
|
21
|
+
body: any,
|
|
22
|
+
): Promise<void> => {
|
|
23
|
+
const requestUrl = `${baseUrl}/new-account`;
|
|
24
|
+
const verified = await jwsVerifier.verify(body, requestUrl);
|
|
25
|
+
|
|
26
|
+
// Account creation must use JWK, not kid
|
|
27
|
+
if (verified.kid) {
|
|
28
|
+
throw new AcmeServerError(
|
|
29
|
+
400,
|
|
30
|
+
'urn:ietf:params:acme:error:malformed',
|
|
31
|
+
'newAccount requests must use JWK, not kid',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { payload, jwk, thumbprint } = verified;
|
|
36
|
+
|
|
37
|
+
// Check if account already exists
|
|
38
|
+
const existing = await accountStore.getByThumbprint(thumbprint);
|
|
39
|
+
if (existing) {
|
|
40
|
+
// If onlyReturnExisting, or just returning the existing account
|
|
41
|
+
res.writeHead(200, {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Location': existing.url,
|
|
44
|
+
});
|
|
45
|
+
res.end(JSON.stringify({
|
|
46
|
+
status: existing.status,
|
|
47
|
+
contact: existing.contact,
|
|
48
|
+
orders: `${baseUrl}/account/${existing.id}/orders`,
|
|
49
|
+
}));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// onlyReturnExisting = true but no account found
|
|
54
|
+
if (payload?.onlyReturnExisting) {
|
|
55
|
+
throw new AcmeServerError(
|
|
56
|
+
400,
|
|
57
|
+
'urn:ietf:params:acme:error:accountDoesNotExist',
|
|
58
|
+
'Account does not exist',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create new account
|
|
63
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
64
|
+
const accountUrl = `${baseUrl}/account/${id}`;
|
|
65
|
+
const account = await accountStore.create({
|
|
66
|
+
id,
|
|
67
|
+
thumbprint,
|
|
68
|
+
url: accountUrl,
|
|
69
|
+
jwk,
|
|
70
|
+
status: 'valid',
|
|
71
|
+
contact: payload?.contact || [],
|
|
72
|
+
createdAt: new Date().toISOString(),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
res.writeHead(201, {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
'Location': accountUrl,
|
|
78
|
+
});
|
|
79
|
+
res.end(JSON.stringify({
|
|
80
|
+
status: account.status,
|
|
81
|
+
contact: account.contact,
|
|
82
|
+
orders: `${baseUrl}/account/${id}/orders`,
|
|
83
|
+
}));
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type * as http from 'node:http';
|
|
2
|
+
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
|
3
|
+
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
|
4
|
+
import type { IServerOrderStore } from './server.interfaces.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /authz/:id — Return authorization with embedded challenges (POST-as-GET).
|
|
8
|
+
*/
|
|
9
|
+
export function createAuthzHandler(
|
|
10
|
+
baseUrl: string,
|
|
11
|
+
jwsVerifier: JwsVerifier,
|
|
12
|
+
orderStore: IServerOrderStore,
|
|
13
|
+
) {
|
|
14
|
+
return async (
|
|
15
|
+
req: http.IncomingMessage,
|
|
16
|
+
res: http.ServerResponse,
|
|
17
|
+
params: Record<string, string>,
|
|
18
|
+
body: any,
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
const authzId = params.id;
|
|
21
|
+
const requestUrl = `${baseUrl}/authz/${authzId}`;
|
|
22
|
+
await jwsVerifier.verify(body, requestUrl);
|
|
23
|
+
|
|
24
|
+
const authz = await orderStore.getAuthorization(authzId);
|
|
25
|
+
if (!authz) {
|
|
26
|
+
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Authorization not found');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build challenge objects
|
|
30
|
+
const challenges = [];
|
|
31
|
+
for (const challengeId of authz.challengeIds) {
|
|
32
|
+
const challenge = await orderStore.getChallenge(challengeId);
|
|
33
|
+
if (challenge) {
|
|
34
|
+
challenges.push({
|
|
35
|
+
type: challenge.type,
|
|
36
|
+
url: `${baseUrl}/challenge/${challenge.id}`,
|
|
37
|
+
status: challenge.status,
|
|
38
|
+
token: challenge.token,
|
|
39
|
+
...(challenge.validated ? { validated: challenge.validated } : {}),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const responseBody: Record<string, any> = {
|
|
45
|
+
identifier: authz.identifier,
|
|
46
|
+
status: authz.status,
|
|
47
|
+
expires: authz.expires,
|
|
48
|
+
challenges,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (authz.wildcard) {
|
|
52
|
+
responseBody.wildcard = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
56
|
+
res.end(JSON.stringify(responseBody));
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type * as http from 'node:http';
|
|
2
|
+
import type { JwsVerifier } from './server.classes.jws.verifier.js';
|
|
3
|
+
import { AcmeServerError } from './server.classes.jws.verifier.js';
|
|
4
|
+
import type { IServerOrderStore } from './server.interfaces.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /cert/:id — Download certificate chain (POST-as-GET).
|
|
8
|
+
*/
|
|
9
|
+
export function createCertHandler(
|
|
10
|
+
baseUrl: string,
|
|
11
|
+
jwsVerifier: JwsVerifier,
|
|
12
|
+
orderStore: IServerOrderStore,
|
|
13
|
+
) {
|
|
14
|
+
return async (
|
|
15
|
+
req: http.IncomingMessage,
|
|
16
|
+
res: http.ServerResponse,
|
|
17
|
+
params: Record<string, string>,
|
|
18
|
+
body: any,
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
const orderId = params.id;
|
|
21
|
+
const requestUrl = `${baseUrl}/cert/${orderId}`;
|
|
22
|
+
await jwsVerifier.verify(body, requestUrl);
|
|
23
|
+
|
|
24
|
+
const certPem = await orderStore.getCertPem(orderId);
|
|
25
|
+
if (!certPem) {
|
|
26
|
+
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Certificate not found');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
res.writeHead(200, { 'Content-Type': 'application/pem-certificate-chain' });
|
|
30
|
+
res.end(certPem);
|
|
31
|
+
};
|
|
32
|
+
}
|