@push.rocks/smartacme 9.1.3 → 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.
Files changed (87) hide show
  1. package/dist_ts/00_commitinfo_data.js +2 -2
  2. package/dist_ts/acme/acme.classes.account.js +1 -1
  3. package/dist_ts/acme/acme.classes.challenge.js +1 -1
  4. package/dist_ts/acme/acme.classes.client.js +1 -1
  5. package/dist_ts/acme/acme.classes.crypto.d.ts +1 -1
  6. package/dist_ts/acme/acme.classes.crypto.js +1 -1
  7. package/dist_ts/acme/acme.classes.directory.js +1 -1
  8. package/dist_ts/acme/acme.classes.error.js +1 -1
  9. package/dist_ts/acme/acme.classes.http-client.js +1 -1
  10. package/dist_ts/acme/acme.classes.order.js +1 -1
  11. package/dist_ts/acme/acme.interfaces.js +1 -1
  12. package/dist_ts/acme/index.js +1 -1
  13. package/dist_ts/certmanagers/index.js +1 -1
  14. package/dist_ts/certmanagers/memory.js +1 -1
  15. package/dist_ts/certmanagers/mongo.js +1 -1
  16. package/dist_ts/handlers/Dns01Handler.js +1 -1
  17. package/dist_ts/handlers/Http01Handler.js +1 -1
  18. package/dist_ts/handlers/Http01MemoryHandler.js +1 -1
  19. package/dist_ts/handlers/IChallengeHandler.js +1 -1
  20. package/dist_ts/handlers/index.js +1 -1
  21. package/dist_ts/index.d.ts +2 -0
  22. package/dist_ts/index.js +4 -1
  23. package/dist_ts/interfaces/accountdata.js +1 -1
  24. package/dist_ts/interfaces/certmanager.js +1 -1
  25. package/dist_ts/interfaces/index.js +1 -1
  26. package/dist_ts/plugins.js +1 -1
  27. package/dist_ts/smartacme.classes.cert.js +1 -1
  28. package/dist_ts/smartacme.classes.certmatcher.js +1 -1
  29. package/dist_ts/smartacme.classes.smartacme.js +1 -1
  30. package/dist_ts_server/index.d.ts +4 -0
  31. package/dist_ts_server/index.js +4 -0
  32. package/dist_ts_server/server.classes.account.store.d.ts +12 -0
  33. package/dist_ts_server/server.classes.account.store.js +25 -0
  34. package/dist_ts_server/server.classes.acmeserver.d.ts +22 -0
  35. package/dist_ts_server/server.classes.acmeserver.js +100 -0
  36. package/dist_ts_server/server.classes.ca.d.ts +25 -0
  37. package/dist_ts_server/server.classes.ca.js +109 -0
  38. package/dist_ts_server/server.classes.challenge.verifier.d.ts +16 -0
  39. package/dist_ts_server/server.classes.challenge.verifier.js +56 -0
  40. package/dist_ts_server/server.classes.jws.verifier.d.ts +31 -0
  41. package/dist_ts_server/server.classes.jws.verifier.js +116 -0
  42. package/dist_ts_server/server.classes.nonce.d.ts +12 -0
  43. package/dist_ts_server/server.classes.nonce.js +32 -0
  44. package/dist_ts_server/server.classes.order.store.d.ts +21 -0
  45. package/dist_ts_server/server.classes.order.store.js +57 -0
  46. package/dist_ts_server/server.classes.router.d.ts +17 -0
  47. package/dist_ts_server/server.classes.router.js +99 -0
  48. package/dist_ts_server/server.handlers.account.d.ts +8 -0
  49. package/dist_ts_server/server.handlers.account.js +59 -0
  50. package/dist_ts_server/server.handlers.authz.d.ts +7 -0
  51. package/dist_ts_server/server.handlers.authz.js +41 -0
  52. package/dist_ts_server/server.handlers.cert.d.ts +7 -0
  53. package/dist_ts_server/server.handlers.cert.js +18 -0
  54. package/dist_ts_server/server.handlers.challenge.d.ts +10 -0
  55. package/dist_ts_server/server.handlers.challenge.js +110 -0
  56. package/dist_ts_server/server.handlers.directory.d.ts +5 -0
  57. package/dist_ts_server/server.handlers.directory.js +23 -0
  58. package/dist_ts_server/server.handlers.finalize.d.ts +8 -0
  59. package/dist_ts_server/server.handlers.finalize.js +65 -0
  60. package/dist_ts_server/server.handlers.nonce.d.ts +7 -0
  61. package/dist_ts_server/server.handlers.nonce.js +17 -0
  62. package/dist_ts_server/server.handlers.order.d.ts +11 -0
  63. package/dist_ts_server/server.handlers.order.js +131 -0
  64. package/dist_ts_server/server.interfaces.d.ts +72 -0
  65. package/dist_ts_server/server.interfaces.js +2 -0
  66. package/package.json +11 -11
  67. package/ts/00_commitinfo_data.ts +1 -1
  68. package/ts/acme/acme.classes.crypto.ts +1 -1
  69. package/ts/index.ts +4 -0
  70. package/ts_server/index.ts +12 -0
  71. package/ts_server/server.classes.account.store.ts +27 -0
  72. package/ts_server/server.classes.acmeserver.ts +128 -0
  73. package/ts_server/server.classes.ca.ts +142 -0
  74. package/ts_server/server.classes.challenge.verifier.ts +61 -0
  75. package/ts_server/server.classes.jws.verifier.ts +153 -0
  76. package/ts_server/server.classes.nonce.ts +36 -0
  77. package/ts_server/server.classes.order.store.ts +72 -0
  78. package/ts_server/server.classes.router.ts +116 -0
  79. package/ts_server/server.handlers.account.ts +85 -0
  80. package/ts_server/server.handlers.authz.ts +58 -0
  81. package/ts_server/server.handlers.cert.ts +32 -0
  82. package/ts_server/server.handlers.challenge.ts +142 -0
  83. package/ts_server/server.handlers.directory.ts +30 -0
  84. package/ts_server/server.handlers.finalize.ts +93 -0
  85. package/ts_server/server.handlers.nonce.ts +29 -0
  86. package/ts_server/server.handlers.order.ts +177 -0
  87. package/ts_server/server.interfaces.ts +98 -0
@@ -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
+ }
@@ -0,0 +1,142 @@
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 { IServerOrderStore, IServerAccountStore } from './server.interfaces.js';
7
+ import type { ChallengeVerifier } from './server.classes.challenge.verifier.js';
8
+
9
+ /**
10
+ * POST /challenge/:id — Trigger or poll an ACME challenge.
11
+ * - POST with `{}` payload: trigger challenge validation
12
+ * - POST-as-GET (null payload): return current challenge state
13
+ */
14
+ export function createChallengeHandler(
15
+ baseUrl: string,
16
+ jwsVerifier: JwsVerifier,
17
+ orderStore: IServerOrderStore,
18
+ accountStore: IServerAccountStore,
19
+ challengeVerifier: ChallengeVerifier,
20
+ ) {
21
+ return async (
22
+ req: http.IncomingMessage,
23
+ res: http.ServerResponse,
24
+ params: Record<string, string>,
25
+ body: any,
26
+ ): Promise<void> => {
27
+ const challengeId = params.id;
28
+ const requestUrl = `${baseUrl}/challenge/${challengeId}`;
29
+ const verified = await jwsVerifier.verify(body, requestUrl);
30
+
31
+ const challenge = await orderStore.getChallenge(challengeId);
32
+ if (!challenge) {
33
+ throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Challenge not found');
34
+ }
35
+
36
+ // POST-as-GET: just return current state
37
+ if (verified.payload === null) {
38
+ sendChallengeResponse(res, challenge, baseUrl);
39
+ return;
40
+ }
41
+
42
+ // Trigger validation (payload should be `{}`)
43
+ if (challenge.status !== 'pending') {
44
+ // Already processing or completed
45
+ sendChallengeResponse(res, challenge, baseUrl);
46
+ return;
47
+ }
48
+
49
+ // Set to processing
50
+ await orderStore.updateChallenge(challengeId, { status: 'processing' });
51
+
52
+ // Get the authorization to find the domain
53
+ const authz = await orderStore.getAuthorization(challenge.authorizationId);
54
+ if (!authz) {
55
+ throw new AcmeServerError(500, 'urn:ietf:params:acme:error:serverInternal', 'Authorization not found');
56
+ }
57
+
58
+ // Resolve the account's JWK for key authorization computation
59
+ const account = await accountStore.getByUrl(verified.kid!);
60
+ if (!account) {
61
+ throw new AcmeServerError(400, 'urn:ietf:params:acme:error:accountDoesNotExist', 'Account not found');
62
+ }
63
+
64
+ const thumbprint = AcmeCrypto.getJwkThumbprint(account.jwk);
65
+ const keyAuth = `${challenge.token}.${thumbprint}`;
66
+
67
+ // Verify the challenge
68
+ let valid = false;
69
+ const domain = authz.identifier.value;
70
+
71
+ if (challenge.type === 'http-01') {
72
+ valid = await challengeVerifier.verifyHttp01(domain, challenge.token, keyAuth);
73
+ } else if (challenge.type === 'dns-01') {
74
+ const hash = crypto.createHash('sha256').update(keyAuth).digest('base64url');
75
+ valid = await challengeVerifier.verifyDns01(domain, hash);
76
+ }
77
+
78
+ if (valid) {
79
+ await orderStore.updateChallenge(challengeId, {
80
+ status: 'valid',
81
+ validated: new Date().toISOString(),
82
+ });
83
+ challenge.status = 'valid';
84
+ challenge.validated = new Date().toISOString();
85
+
86
+ // Check if all challenges for this authorization's required type are valid
87
+ // One valid challenge is enough to validate the authorization
88
+ await orderStore.updateAuthorization(authz.id, { status: 'valid' });
89
+
90
+ // Check if all authorizations for the order are now valid
91
+ const order = await orderStore.getOrder(authz.orderId);
92
+ if (order && order.status === 'pending') {
93
+ let allValid = true;
94
+ for (const authzId of order.authorizationIds) {
95
+ const a = await orderStore.getAuthorization(authzId);
96
+ if (!a || a.status !== 'valid') {
97
+ allValid = false;
98
+ break;
99
+ }
100
+ }
101
+ if (allValid) {
102
+ await orderStore.updateOrder(order.id, { status: 'ready' });
103
+ }
104
+ }
105
+ } else {
106
+ await orderStore.updateChallenge(challengeId, {
107
+ status: 'invalid',
108
+ error: {
109
+ type: 'urn:ietf:params:acme:error:incorrectResponse',
110
+ detail: `Challenge verification failed for ${domain}`,
111
+ },
112
+ });
113
+ challenge.status = 'invalid';
114
+
115
+ // Mark authorization as invalid too
116
+ await orderStore.updateAuthorization(authz.id, { status: 'invalid' });
117
+ }
118
+
119
+ sendChallengeResponse(res, challenge, baseUrl);
120
+ };
121
+ }
122
+
123
+ function sendChallengeResponse(
124
+ res: http.ServerResponse,
125
+ challenge: { id: string; type: string; status: string; token: string; validated?: string },
126
+ baseUrl: string,
127
+ ): void {
128
+ const responseBody: Record<string, any> = {
129
+ type: challenge.type,
130
+ url: `${baseUrl}/challenge/${challenge.id}`,
131
+ status: challenge.status,
132
+ token: challenge.token,
133
+ };
134
+ if (challenge.validated) {
135
+ responseBody.validated = challenge.validated;
136
+ }
137
+ res.writeHead(200, {
138
+ 'Content-Type': 'application/json',
139
+ 'Link': `<${baseUrl}/directory>;rel="index"`,
140
+ });
141
+ res.end(JSON.stringify(responseBody));
142
+ }