@push.rocks/smartacme 9.5.0 → 9.7.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/.smartconfig.json +12 -7
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/acme/acme.classes.client.d.ts +2 -0
- package/dist_ts/acme/acme.classes.client.js +5 -2
- package/dist_ts/acme/acme.classes.http-client.d.ts +2 -1
- package/dist_ts/acme/acme.classes.http-client.js +4 -2
- package/dist_ts/acme/acme.classes.order.d.ts +3 -0
- package/dist_ts/acme/acme.classes.order.js +11 -2
- package/dist_ts/classes.exact-certificate-issuer.d.ts +37 -0
- package/dist_ts/classes.exact-certificate-issuer.js +397 -0
- package/dist_ts/handlers/IChallengeHandler.d.ts +8 -3
- package/dist_ts/handlers/index.d.ts +1 -1
- package/dist_ts/index.d.ts +2 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces/exact-issuance.d.ts +79 -0
- package/dist_ts/interfaces/exact-issuance.js +2 -0
- package/dist_ts/plugins.d.ts +5 -1
- package/dist_ts/plugins.js +6 -2
- package/dist_ts/smartacme.classes.smartacme.d.ts +22 -0
- package/dist_ts/smartacme.classes.smartacme.js +141 -16
- package/package.json +11 -15
- package/readme.md +90 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/acme/acme.classes.client.ts +6 -1
- package/ts/acme/acme.classes.http-client.ts +2 -1
- package/ts/acme/acme.classes.order.ts +12 -1
- package/ts/classes.exact-certificate-issuer.ts +408 -0
- package/ts/handlers/IChallengeHandler.ts +10 -4
- package/ts/handlers/index.ts +2 -2
- package/ts/index.ts +2 -0
- package/ts/interfaces/exact-issuance.ts +104 -0
- package/ts/plugins.ts +5 -1
- package/ts/smartacme.classes.smartacme.ts +151 -15
- package/readme.hints.md +0 -81
- package/readme.plan.md +0 -3
|
@@ -20,7 +20,7 @@ export class AcmeHttpClient {
|
|
|
20
20
|
private httpsAgent: https.Agent;
|
|
21
21
|
private httpAgent: http.Agent;
|
|
22
22
|
|
|
23
|
-
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger) {
|
|
23
|
+
constructor(directoryUrl: string, accountKeyPem: string, logger?: TAcmeLogger, public readonly signal?: AbortSignal) {
|
|
24
24
|
this.directoryUrl = directoryUrl;
|
|
25
25
|
this.accountKeyPem = accountKeyPem;
|
|
26
26
|
this.logger = logger;
|
|
@@ -199,6 +199,7 @@ export class AcmeHttpClient {
|
|
|
199
199
|
method,
|
|
200
200
|
headers: requestHeaders,
|
|
201
201
|
agent: isHttps ? this.httpsAgent : this.httpAgent,
|
|
202
|
+
signal: this.signal,
|
|
202
203
|
};
|
|
203
204
|
|
|
204
205
|
const req = lib.request(options, (res) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AcmeCrypto } from './acme.classes.crypto.js';
|
|
2
2
|
import { AcmeError } from './acme.classes.error.js';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
4
|
import type { AcmeHttpClient } from './acme.classes.http-client.js';
|
|
4
5
|
import type {
|
|
5
6
|
IAcmeAuthorization,
|
|
@@ -33,6 +34,12 @@ export class AcmeOrderManager {
|
|
|
33
34
|
return order;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Read an acknowledged order without creating or finalizing another one. */
|
|
38
|
+
async get(url: string): Promise<IAcmeOrder> {
|
|
39
|
+
const response = await this.httpClient.signedRequest(url, null);
|
|
40
|
+
return { ...response.data as IAcmeOrder, url };
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
/**
|
|
37
44
|
* Retrieve all authorizations for an order (POST-as-GET each authorization URL)
|
|
38
45
|
*/
|
|
@@ -117,9 +124,13 @@ export class AcmeOrderManager {
|
|
|
117
124
|
const retryAfter = parseInt(response.headers['retry-after'] || '0', 10);
|
|
118
125
|
const delay =
|
|
119
126
|
retryAfter > 0 ? retryAfter * 1000 : Math.min(initialDelay * Math.pow(2, i), 30000);
|
|
120
|
-
await
|
|
127
|
+
await this.wait(delay);
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
throw new Error(`Timeout waiting for valid status after ${maxAttempts} attempts`);
|
|
124
131
|
}
|
|
132
|
+
|
|
133
|
+
private async wait(milliseconds: number): Promise<void> {
|
|
134
|
+
await delay(milliseconds, undefined, { signal: this.httpClient.signal });
|
|
135
|
+
}
|
|
125
136
|
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IExactCertificate,
|
|
4
|
+
IExactCertificateIdentity,
|
|
5
|
+
IExactCertificateRequest,
|
|
6
|
+
IExactIssuanceInfo,
|
|
7
|
+
IExactIssuanceRecord,
|
|
8
|
+
IExactIssuanceRecoveryRequest,
|
|
9
|
+
IExactIssuanceStore,
|
|
10
|
+
TExactCertificateResult,
|
|
11
|
+
} from './interfaces/exact-issuance.js';
|
|
12
|
+
|
|
13
|
+
export function normalizeCertificateIdentifiers(input: readonly string[]): string[] {
|
|
14
|
+
if (!Array.isArray(input) || input.length < 1 || input.length > 100) {
|
|
15
|
+
throw new Error('Between one and 100 explicit DNS identifiers are required');
|
|
16
|
+
}
|
|
17
|
+
return [...new Set(Array.from(input, (value) => {
|
|
18
|
+
if (typeof value !== 'string' || value.length > 1024) throw new Error('Invalid DNS identifier');
|
|
19
|
+
const trimmed = value.trim().toLowerCase().replace(/\.$/, '');
|
|
20
|
+
if (/[/\\:@?#%\s]/.test(trimmed)) throw new Error('Invalid DNS identifier');
|
|
21
|
+
const wildcard = trimmed.startsWith('*.');
|
|
22
|
+
const name = plugins.url.domainToASCII(wildcard ? trimmed.slice(2) : trimmed);
|
|
23
|
+
if (!name || plugins.net.isIP(name) !== 0 || name.length > 253 || name.split('.').length < 2
|
|
24
|
+
|| name.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
|
|
25
|
+
throw new Error('Invalid DNS identifier');
|
|
26
|
+
}
|
|
27
|
+
return `${wildcard ? '*.' : ''}${name}`;
|
|
28
|
+
}))].sort();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IExactCertificateIssuerOptions {
|
|
32
|
+
store: IExactIssuanceStore;
|
|
33
|
+
client: plugins.acme.AcmeClient;
|
|
34
|
+
directoryUrl: string;
|
|
35
|
+
accountThumbprint: string;
|
|
36
|
+
dns: plugins.smartdnsClient.Smartdns;
|
|
37
|
+
handlers: plugins.handlers.IChallengeHandler<plugins.tsclass.network.IDnsChallenge>[];
|
|
38
|
+
signal: AbortSignal;
|
|
39
|
+
/** Explicit propagation grace period, including for custom ACME authorities. */
|
|
40
|
+
dnsPropagationDelayMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Owns exact-name issuance and recovery; the embedding service supplies storage. */
|
|
44
|
+
export class ExactCertificateIssuer {
|
|
45
|
+
constructor(private options: IExactCertificateIssuerOptions) {}
|
|
46
|
+
|
|
47
|
+
public identity(request: IExactCertificateRequest): IExactCertificateIdentity {
|
|
48
|
+
if (typeof request.namespace !== 'string' || !/^[\x21-\x7e]{1,200}$/.test(request.namespace)) {
|
|
49
|
+
throw new Error('A nonempty printable isolation namespace of at most 200 characters is required');
|
|
50
|
+
}
|
|
51
|
+
const identifiers = normalizeCertificateIdentifiers(request.identifiers);
|
|
52
|
+
const binding = {
|
|
53
|
+
namespace: request.namespace,
|
|
54
|
+
identifiers,
|
|
55
|
+
directoryUrl: this.options.directoryUrl,
|
|
56
|
+
accountThumbprint: this.options.accountThumbprint,
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
...binding,
|
|
60
|
+
certificateKey: `exact-v1:${plugins.crypto.createHash('sha256').update(JSON.stringify(binding)).digest('hex')}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
public async status(request: IExactCertificateRequest): Promise<IExactIssuanceInfo | null> {
|
|
65
|
+
const identity = this.identity(request);
|
|
66
|
+
const record = await this.options.store.getIssuance(identity.certificateKey);
|
|
67
|
+
if (record) this.validateRecord(record, identity);
|
|
68
|
+
return record ? this.info(record) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Validate the cache before taking any issuance or CA rate-limit slot. */
|
|
72
|
+
public async cached(request: IExactCertificateRequest): Promise<TExactCertificateResult | null> {
|
|
73
|
+
const identity = this.identity(request);
|
|
74
|
+
const certificate = await this.options.store.getCertificate(identity.certificateKey);
|
|
75
|
+
if (!certificate) return null;
|
|
76
|
+
this.validateCertificate(certificate, identity);
|
|
77
|
+
const record = await this.options.store.getIssuance(identity.certificateKey);
|
|
78
|
+
if (record?.status !== 'ready' || certificate.id !== record.issuanceId
|
|
79
|
+
|| Date.now() >= certificate.renewAfter || Date.now() >= certificate.validUntil) return null;
|
|
80
|
+
this.validateRecord(record, identity);
|
|
81
|
+
return { status: 'ready', certificate, issuance: this.info(record) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public async run(request: IExactCertificateRequest): Promise<TExactCertificateResult> {
|
|
85
|
+
this.options.signal.throwIfAborted();
|
|
86
|
+
const identity = this.identity(request);
|
|
87
|
+
let record = await this.options.store.getIssuance(identity.certificateKey);
|
|
88
|
+
const cached = await this.options.store.getCertificate(identity.certificateKey);
|
|
89
|
+
if (cached) {
|
|
90
|
+
this.validateCertificate(cached, identity);
|
|
91
|
+
if (record?.status === 'ready' && cached.id === record.issuanceId && Date.now() < cached.renewAfter) {
|
|
92
|
+
this.validateRecord(record, identity);
|
|
93
|
+
return { status: 'ready', certificate: cached, issuance: this.info(record) };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (record?.status === 'ready' && !cached) {
|
|
97
|
+
this.validateRecord(record, identity);
|
|
98
|
+
return await this.advance(record, true);
|
|
99
|
+
}
|
|
100
|
+
if (!record || record.status === 'ready') {
|
|
101
|
+
const [privateKey, csr] = await plugins.acme.AcmeCrypto.createCsr({
|
|
102
|
+
commonName: identity.identifiers[0], altNames: identity.identifiers.slice(1),
|
|
103
|
+
});
|
|
104
|
+
const next: IExactIssuanceRecord = {
|
|
105
|
+
...identity, issuanceId: plugins.crypto.randomUUID(), revision: (record?.revision ?? -1) + 1,
|
|
106
|
+
status: 'prepared', createdAt: Date.now(), updatedAt: Date.now(), privateKey, csr,
|
|
107
|
+
pendingChallenges: [],
|
|
108
|
+
};
|
|
109
|
+
this.options.signal.throwIfAborted();
|
|
110
|
+
if (!await this.options.store.compareAndSetIssuance(identity.certificateKey, record?.revision ?? null, next)) {
|
|
111
|
+
const current = await this.requiredRecord(identity.certificateKey);
|
|
112
|
+
return { status: 'pending', issuance: this.info(current) };
|
|
113
|
+
}
|
|
114
|
+
record = next;
|
|
115
|
+
}
|
|
116
|
+
this.validateRecord(record, identity);
|
|
117
|
+
return await this.advance(record, false);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public async recover(request: IExactIssuanceRecoveryRequest): Promise<TExactCertificateResult> {
|
|
121
|
+
if (!['recheck', 'retry-proven-unapplied', 'abandon'].includes(request.action)
|
|
122
|
+
|| !Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 0
|
|
123
|
+
|| typeof request.issuanceId !== 'string' || !request.issuanceId) {
|
|
124
|
+
throw new Error('Invalid issuance recovery request');
|
|
125
|
+
}
|
|
126
|
+
const identity = this.identity(request);
|
|
127
|
+
let record = await this.requiredRecord(identity.certificateKey);
|
|
128
|
+
this.validateRecord(record, identity);
|
|
129
|
+
if (record.issuanceId !== request.issuanceId || record.revision !== request.expectedRevision) {
|
|
130
|
+
throw new Error('Issuance recovery revision conflict');
|
|
131
|
+
}
|
|
132
|
+
if (request.action === 'abandon') {
|
|
133
|
+
if (record.status === 'ready') throw new Error('A completed issuance cannot be abandoned');
|
|
134
|
+
record = await this.save(record, { status: 'abandoned_unverified' });
|
|
135
|
+
await this.cleanup(record);
|
|
136
|
+
return { status: 'pending', issuance: this.info(await this.requiredRecord(record.certificateKey)) };
|
|
137
|
+
}
|
|
138
|
+
if (record.status === 'abandoned_unverified') {
|
|
139
|
+
throw new Error('Abandoned issuance requires external resolution; new issuance remains blocked');
|
|
140
|
+
}
|
|
141
|
+
if (request.action === 'retry-proven-unapplied') {
|
|
142
|
+
// Only an acknowledged rejection of newOrder proves no order was created.
|
|
143
|
+
if (record.order || record.status !== 'failed' || record.errorCode !== 'rate_limited') {
|
|
144
|
+
throw new Error('The previous external effect is not proven unapplied');
|
|
145
|
+
}
|
|
146
|
+
if (record.retryAfter && record.retryAfter > Date.now()) {
|
|
147
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
148
|
+
}
|
|
149
|
+
record = await this.save(record, { status: 'prepared', errorCode: undefined, retryAfter: undefined });
|
|
150
|
+
return await this.advance(record, false);
|
|
151
|
+
}
|
|
152
|
+
return await this.advance(record, true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async advance(initial: IExactIssuanceRecord, recheckOnly: boolean): Promise<TExactCertificateResult> {
|
|
156
|
+
let record = initial;
|
|
157
|
+
this.options.signal.throwIfAborted();
|
|
158
|
+
if (record.status === 'abandoned_unverified' || record.status === 'failed') {
|
|
159
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
160
|
+
}
|
|
161
|
+
if (record.status === 'creating' || (record.status === 'indeterminate' && !record.order)) {
|
|
162
|
+
record = await this.save(record, { status: 'indeterminate', errorCode: 'order_outcome_unknown' });
|
|
163
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
164
|
+
}
|
|
165
|
+
if (record.status === 'prepared') {
|
|
166
|
+
if (recheckOnly) return { status: 'pending', issuance: this.info(record) };
|
|
167
|
+
// Prove challenge support before creating an irreversible CA order.
|
|
168
|
+
await this.handler(record);
|
|
169
|
+
record = await this.save(record, { status: 'creating' });
|
|
170
|
+
let order: plugins.acme.IAcmeOrder;
|
|
171
|
+
try {
|
|
172
|
+
this.options.signal.throwIfAborted();
|
|
173
|
+
order = await this.options.client.createOrder({
|
|
174
|
+
identifiers: record.identifiers.map((value) => ({ type: 'dns', value })),
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const rejected = error instanceof plugins.acme.AcmeError && error.isRateLimited;
|
|
178
|
+
record = await this.save(record, {
|
|
179
|
+
status: rejected ? 'failed' : 'indeterminate',
|
|
180
|
+
errorCode: rejected ? 'rate_limited' : 'order_outcome_unknown',
|
|
181
|
+
retryAfter: rejected ? Date.now() + Math.max(error.retryAfter, 60) * 1000 : undefined,
|
|
182
|
+
});
|
|
183
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
184
|
+
}
|
|
185
|
+
if (!order.url) {
|
|
186
|
+
record = await this.save(record, { status: 'indeterminate', errorCode: 'order_outcome_unknown' });
|
|
187
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
188
|
+
}
|
|
189
|
+
record = await this.save(record, { order, status: 'authorizing' });
|
|
190
|
+
}
|
|
191
|
+
if (!record.order) throw new Error('Issuance record has no acknowledged order');
|
|
192
|
+
const observed = await this.options.client.getOrder(record.order.url);
|
|
193
|
+
this.validateOrder(observed, record);
|
|
194
|
+
record = await this.save(record, { order: observed, errorCode: undefined });
|
|
195
|
+
if (observed.status === 'invalid' || observed.status === 'expired' || observed.status === 'revoked') {
|
|
196
|
+
record = await this.save(record, { status: 'failed', errorCode: 'order_invalid' });
|
|
197
|
+
await this.cleanup(record);
|
|
198
|
+
return { status: 'pending', issuance: this.info(await this.requiredRecord(record.certificateKey)) };
|
|
199
|
+
}
|
|
200
|
+
if (observed.status === 'valid') return await this.finish(record);
|
|
201
|
+
if (recheckOnly) {
|
|
202
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
203
|
+
}
|
|
204
|
+
if (record.status === 'finalizing' || record.status === 'indeterminate') {
|
|
205
|
+
// Acknowledged processing/ready observations do not prove a previous
|
|
206
|
+
// finalize request was unapplied. Never send a new CSR on uncertainty.
|
|
207
|
+
record = await this.save(record, { status: 'indeterminate', errorCode: 'order_outcome_unknown' });
|
|
208
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
209
|
+
}
|
|
210
|
+
if (observed.status === 'pending') {
|
|
211
|
+
record = await this.authorize(record);
|
|
212
|
+
if (record.status === 'failed') return { status: 'pending', issuance: this.info(record) };
|
|
213
|
+
}
|
|
214
|
+
const ready = await this.options.client.getOrder(record.order!.url);
|
|
215
|
+
this.validateOrder(ready, record);
|
|
216
|
+
record = await this.save(record, { order: ready });
|
|
217
|
+
if (ready.status === 'valid') return await this.finish(record);
|
|
218
|
+
if (ready.status !== 'ready') {
|
|
219
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
220
|
+
}
|
|
221
|
+
record = await this.save(record, { status: 'finalizing' });
|
|
222
|
+
try {
|
|
223
|
+
this.options.signal.throwIfAborted();
|
|
224
|
+
await this.options.client.finalizeOrder(structuredClone(ready), record.csr);
|
|
225
|
+
} catch {
|
|
226
|
+
// Preserve the acknowledged order and original key/CSR for safe recheck.
|
|
227
|
+
record = await this.save(record, { status: 'indeterminate', errorCode: 'order_outcome_unknown' });
|
|
228
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
229
|
+
}
|
|
230
|
+
const finalized = await this.options.client.getOrder(ready.url);
|
|
231
|
+
this.validateOrder(finalized, record);
|
|
232
|
+
record = await this.save(record, { order: finalized });
|
|
233
|
+
return finalized.status === 'valid'
|
|
234
|
+
? await this.finish(record)
|
|
235
|
+
: { status: 'pending', issuance: this.info(record) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async authorize(initial: IExactIssuanceRecord): Promise<IExactIssuanceRecord> {
|
|
239
|
+
let record = initial;
|
|
240
|
+
const handler = await this.handler(record);
|
|
241
|
+
const authorizations = await this.options.client.getAuthorizations(record.order!);
|
|
242
|
+
for (const authorization of authorizations) {
|
|
243
|
+
this.options.signal.throwIfAborted();
|
|
244
|
+
const identifier = `${authorization.wildcard ? '*.' : ''}${authorization.identifier.value}`;
|
|
245
|
+
if (authorization.identifier.type !== 'dns' || !record.identifiers.includes(identifier)) {
|
|
246
|
+
throw new Error('CA authorization is outside the requested identifiers');
|
|
247
|
+
}
|
|
248
|
+
if (authorization.status === 'valid') continue;
|
|
249
|
+
if (authorization.status !== 'pending') {
|
|
250
|
+
return await this.save(record, { status: 'failed', errorCode: 'authorization_failed' });
|
|
251
|
+
}
|
|
252
|
+
const challenge = authorization.challenges.find((value) => value.type === 'dns-01');
|
|
253
|
+
if (!challenge) throw new Error('The CA did not offer DNS-01');
|
|
254
|
+
const input = {
|
|
255
|
+
hostName: `_acme-challenge.${authorization.identifier.value}`,
|
|
256
|
+
challenge: this.options.client.getChallengeKeyAuthorization(challenge),
|
|
257
|
+
};
|
|
258
|
+
if (!record.pendingChallenges.some((value) => value.hostName === input.hostName && value.challenge === input.challenge)) {
|
|
259
|
+
record = await this.save(record, { pendingChallenges: [...record.pendingChallenges, input] });
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
await handler.prepare(input, this.challengeContext(record));
|
|
263
|
+
if (handler.verify) {
|
|
264
|
+
await handler.verify(input, this.challengeContext(record));
|
|
265
|
+
} else {
|
|
266
|
+
let visible = false;
|
|
267
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
268
|
+
this.options.signal.throwIfAborted();
|
|
269
|
+
const records = await this.options.dns.getRecords(input.hostName, 'TXT', 0);
|
|
270
|
+
if (records.some((value) => value.value === input.challenge)) { visible = true; break; }
|
|
271
|
+
await plugins.timers.setTimeout(1000, undefined, { signal: this.options.signal });
|
|
272
|
+
}
|
|
273
|
+
if (!visible) throw new Error('DNS-01 propagation timed out');
|
|
274
|
+
}
|
|
275
|
+
await plugins.timers.setTimeout(this.options.dnsPropagationDelayMs, undefined, { signal: this.options.signal });
|
|
276
|
+
await this.options.client.completeChallenge(challenge);
|
|
277
|
+
await this.options.client.waitForValidStatus(challenge);
|
|
278
|
+
} finally {
|
|
279
|
+
// Persist cleanup intent until cleanup actually succeeds, including
|
|
280
|
+
// failure during prepare, process shutdown, or certificate retrieval.
|
|
281
|
+
await handler.cleanup(input, this.challengeContext(record));
|
|
282
|
+
record = await this.save(record, {
|
|
283
|
+
pendingChallenges: record.pendingChallenges.filter((value) => value.hostName !== input.hostName || value.challenge !== input.challenge),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async finish(initial: IExactIssuanceRecord): Promise<TExactCertificateResult> {
|
|
291
|
+
let record = initial;
|
|
292
|
+
if (!record.order?.certificate) throw new Error('Completed order has no certificate URL');
|
|
293
|
+
const pem = await this.options.client.getCertificate(record.order);
|
|
294
|
+
const x509 = new plugins.crypto.X509Certificate(pem);
|
|
295
|
+
const validFrom = new Date(x509.validFrom).getTime();
|
|
296
|
+
const validUntil = new Date(x509.validTo).getTime();
|
|
297
|
+
const certificate: IExactCertificate = {
|
|
298
|
+
...this.identity(record), id: record.issuanceId, created: Date.now(),
|
|
299
|
+
privateKey: record.privateKey, csr: record.csr, publicKey: pem, validFrom, validUntil,
|
|
300
|
+
renewAfter: validUntil - Math.min(10 * 86400000, (validUntil - validFrom) / 3),
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
this.validateCertificate(certificate, record);
|
|
304
|
+
if (validUntil <= Date.now()) throw new Error('CA returned an expired certificate');
|
|
305
|
+
} catch {
|
|
306
|
+
record = await this.save(record, { status: 'failed', errorCode: 'certificate_invalid' });
|
|
307
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
308
|
+
}
|
|
309
|
+
await this.cleanup(record);
|
|
310
|
+
record = await this.requiredRecord(record.certificateKey);
|
|
311
|
+
if (record.status === 'abandoned_unverified') {
|
|
312
|
+
return { status: 'pending', issuance: this.info(record) };
|
|
313
|
+
}
|
|
314
|
+
await this.options.store.storeCertificate(certificate);
|
|
315
|
+
record = await this.save(record, { status: 'ready', errorCode: undefined });
|
|
316
|
+
return { status: 'ready', certificate, issuance: this.info(record) };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async handler(record: IExactCertificateIdentity) {
|
|
320
|
+
const handler = this.options.handlers.find((value) => value.getSupportedTypes().includes('dns-01'));
|
|
321
|
+
if (!handler) throw new Error('Exact issuance requires DNS-01');
|
|
322
|
+
for (const identifier of record.identifiers) {
|
|
323
|
+
if (!await handler.checkWetherDomainIsSupported(identifier.replace(/^\*\./, ''))) {
|
|
324
|
+
throw new Error('DNS-01 ownership is unavailable for a requested identifier');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return handler;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async cleanup(initial: IExactIssuanceRecord): Promise<void> {
|
|
331
|
+
let record = initial;
|
|
332
|
+
if (!record.pendingChallenges.length) return;
|
|
333
|
+
const handler = this.options.handlers.find((value) => value.getSupportedTypes().includes('dns-01'));
|
|
334
|
+
if (!handler) throw new Error('DNS-01 cleanup handler is unavailable');
|
|
335
|
+
for (const input of [...record.pendingChallenges]) {
|
|
336
|
+
await handler.cleanup(input, this.challengeContext(record));
|
|
337
|
+
record = await this.save(record, {
|
|
338
|
+
pendingChallenges: record.pendingChallenges.filter((value) => value.hostName !== input.hostName || value.challenge !== input.challenge),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private challengeContext(record: IExactIssuanceRecord): plugins.handlers.IChallengeContext {
|
|
344
|
+
return { operationId: plugins.crypto.createHash('sha256')
|
|
345
|
+
.update(JSON.stringify(['smartacme-exact-challenge-v1', record.certificateKey, record.issuanceId])).digest('hex') };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private validateRecord(record: IExactIssuanceRecord, identity: IExactCertificateIdentity): void {
|
|
349
|
+
if (record.certificateKey !== identity.certificateKey
|
|
350
|
+
|| this.identity(record).certificateKey !== identity.certificateKey
|
|
351
|
+
|| record.accountThumbprint !== identity.accountThumbprint || record.directoryUrl !== identity.directoryUrl) {
|
|
352
|
+
throw new Error('Issuance storage identity mismatch');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private validateOrder(order: plugins.acme.IAcmeOrder, record: IExactIssuanceRecord): void {
|
|
357
|
+
if (!order.url || order.identifiers.some((value) => value.type !== 'dns')
|
|
358
|
+
|| JSON.stringify(normalizeCertificateIdentifiers(order.identifiers.map((value) => value.value))) !== JSON.stringify(record.identifiers)) {
|
|
359
|
+
throw new Error('CA order identifiers do not match the certificate request');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private validateCertificate(certificate: IExactCertificate, identity: IExactCertificateIdentity): void {
|
|
364
|
+
if (certificate.certificateKey !== identity.certificateKey
|
|
365
|
+
|| this.identity(certificate).certificateKey !== identity.certificateKey
|
|
366
|
+
|| certificate.accountThumbprint !== identity.accountThumbprint || certificate.directoryUrl !== identity.directoryUrl) {
|
|
367
|
+
throw new Error('Certificate storage identity mismatch');
|
|
368
|
+
}
|
|
369
|
+
const parsed = new plugins.x509.X509Certificate(certificate.publicKey);
|
|
370
|
+
const names = parsed.getExtension(plugins.x509.SubjectAlternativeNameExtension)?.names.toJSON();
|
|
371
|
+
if (!names || names.some((value) => value.type !== 'dns')
|
|
372
|
+
|| JSON.stringify(normalizeCertificateIdentifiers(names.map((value) => value.value))) !== JSON.stringify(identity.identifiers)) {
|
|
373
|
+
throw new Error('Certificate identifiers exceed or differ from the requested scope');
|
|
374
|
+
}
|
|
375
|
+
const x509 = new plugins.crypto.X509Certificate(certificate.publicKey);
|
|
376
|
+
if (x509.ca || !x509.checkPrivateKey(plugins.crypto.createPrivateKey(certificate.privateKey))
|
|
377
|
+
|| new Date(x509.validFrom).getTime() !== certificate.validFrom
|
|
378
|
+
|| new Date(x509.validTo).getTime() !== certificate.validUntil
|
|
379
|
+
|| certificate.validFrom > Date.now() + 300000
|
|
380
|
+
|| !Number.isFinite(certificate.renewAfter) || certificate.renewAfter >= certificate.validUntil) {
|
|
381
|
+
throw new Error('Invalid certificate key, lifetime, or constraints');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private info(record: IExactIssuanceRecord): IExactIssuanceInfo {
|
|
386
|
+
return {
|
|
387
|
+
certificateKey: record.certificateKey, issuanceId: record.issuanceId,
|
|
388
|
+
identifiers: [...record.identifiers], status: record.status, revision: record.revision,
|
|
389
|
+
createdAt: record.createdAt, updatedAt: record.updatedAt,
|
|
390
|
+
errorCode: record.errorCode, retryAfter: record.retryAfter,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private async requiredRecord(key: string): Promise<IExactIssuanceRecord> {
|
|
395
|
+
const record = await this.options.store.getIssuance(key);
|
|
396
|
+
if (!record) throw new Error('Issuance recovery record is missing');
|
|
397
|
+
return record;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private async save(record: IExactIssuanceRecord, patch: Partial<Pick<IExactIssuanceRecord,
|
|
401
|
+
'status' | 'order' | 'pendingChallenges' | 'errorCode' | 'retryAfter'>>): Promise<IExactIssuanceRecord> {
|
|
402
|
+
const next = { ...record, ...patch, revision: record.revision + 1, updatedAt: Date.now() };
|
|
403
|
+
if (!await this.options.store.compareAndSetIssuance(record.certificateKey, record.revision, next)) {
|
|
404
|
+
throw new Error('Issuance changed concurrently; reload its durable state before proceeding');
|
|
405
|
+
}
|
|
406
|
+
return next;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** Stable resource owner across preparation, retries, cleanup and exact recovery. */
|
|
2
|
+
export interface IChallengeContext {
|
|
3
|
+
/** Opaque identity for one order attempt; never reuse resources across owners. */
|
|
4
|
+
operationId: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* Pluggable interface for ACME challenge handlers.
|
|
3
9
|
* Supports DNS-01, HTTP-01, TLS-ALPN-01, or custom challenge types.
|
|
@@ -10,15 +16,15 @@ export interface IChallengeHandler<T> {
|
|
|
10
16
|
/**
|
|
11
17
|
* Prepare the challenge: set DNS record, start HTTP/TLS server, etc.
|
|
12
18
|
*/
|
|
13
|
-
prepare(ch: T): Promise<void>;
|
|
19
|
+
prepare(ch: T, context?: IChallengeContext): Promise<void>;
|
|
14
20
|
/**
|
|
15
21
|
* Optional extra verify step (HTTP GET, ALPN handshake).
|
|
16
22
|
*/
|
|
17
|
-
verify?(ch: T): Promise<void>;
|
|
23
|
+
verify?(ch: T, context?: IChallengeContext): Promise<void>;
|
|
18
24
|
/**
|
|
19
25
|
* Clean up resources: remove DNS record, stop server.
|
|
20
26
|
*/
|
|
21
|
-
cleanup(ch: T): Promise<void>;
|
|
27
|
+
cleanup(ch: T, context?: IChallengeContext): Promise<void>;
|
|
22
28
|
|
|
23
29
|
checkWetherDomainIsSupported(domainArg: string): Promise<boolean>;
|
|
24
|
-
}
|
|
30
|
+
}
|
package/ts/handlers/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { IChallengeHandler } from './IChallengeHandler.js';
|
|
1
|
+
export type { IChallengeHandler, IChallengeContext } from './IChallengeHandler.js';
|
|
2
2
|
// Removed legacy handler adapter
|
|
3
3
|
export { Dns01Handler } from './Dns01Handler.js';
|
|
4
4
|
export { Http01Webroot } from './Http01Handler.js';
|
|
5
|
-
export { Http01MemoryHandler } from './Http01MemoryHandler.js';
|
|
5
|
+
export { Http01MemoryHandler } from './Http01MemoryHandler.js';
|
package/ts/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from './smartacme.classes.smartacme.js';
|
|
2
2
|
export { SmartacmeCert as Cert } from './smartacme.classes.cert.js';
|
|
3
3
|
export type { ICertManager } from './interfaces/certmanager.js';
|
|
4
|
+
export type * from './interfaces/exact-issuance.js';
|
|
5
|
+
export { normalizeCertificateIdentifiers } from './classes.exact-certificate-issuer.js';
|
|
4
6
|
|
|
5
7
|
// certmanagers
|
|
6
8
|
import * as certmanagers from './certmanagers/index.js';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { IAcmeOrder } from '../acme/acme.interfaces.js';
|
|
2
|
+
|
|
3
|
+
/** Explicit identifiers never inherit parent or wildcard coverage. */
|
|
4
|
+
export interface IExactCertificateRequest {
|
|
5
|
+
namespace: string;
|
|
6
|
+
identifiers: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IExactCertificateIdentity extends IExactCertificateRequest {
|
|
10
|
+
certificateKey: string;
|
|
11
|
+
directoryUrl: string;
|
|
12
|
+
accountThumbprint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface IExactCertificate extends IExactCertificateIdentity {
|
|
16
|
+
id: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
publicKey: string;
|
|
19
|
+
csr: string;
|
|
20
|
+
created: number;
|
|
21
|
+
validFrom: number;
|
|
22
|
+
validUntil: number;
|
|
23
|
+
renewAfter: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TExactIssuanceStatus =
|
|
27
|
+
| 'prepared'
|
|
28
|
+
| 'creating'
|
|
29
|
+
| 'authorizing'
|
|
30
|
+
| 'finalizing'
|
|
31
|
+
| 'ready'
|
|
32
|
+
| 'failed'
|
|
33
|
+
| 'indeterminate'
|
|
34
|
+
| 'abandoned_unverified';
|
|
35
|
+
|
|
36
|
+
export type TExactIssuanceErrorCode =
|
|
37
|
+
| 'order_outcome_unknown'
|
|
38
|
+
| 'order_invalid'
|
|
39
|
+
| 'authorization_failed'
|
|
40
|
+
| 'certificate_invalid'
|
|
41
|
+
| 'challenge_cleanup_pending'
|
|
42
|
+
| 'rate_limited'
|
|
43
|
+
| 'transport_unavailable';
|
|
44
|
+
|
|
45
|
+
export interface IExactDnsChallenge {
|
|
46
|
+
hostName: string;
|
|
47
|
+
challenge: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Sensitive durable state. Never return this record through a status API. */
|
|
51
|
+
export interface IExactIssuanceRecord extends IExactCertificateIdentity {
|
|
52
|
+
issuanceId: string;
|
|
53
|
+
revision: number;
|
|
54
|
+
status: TExactIssuanceStatus;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
updatedAt: number;
|
|
57
|
+
privateKey: string;
|
|
58
|
+
csr: string;
|
|
59
|
+
order?: IAcmeOrder;
|
|
60
|
+
pendingChallenges: IExactDnsChallenge[];
|
|
61
|
+
errorCode?: TExactIssuanceErrorCode;
|
|
62
|
+
retryAfter?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Storage must acknowledge durable writes and atomically compare revisions.
|
|
67
|
+
* A null revision means insert only if absent. No filesystem fallback is used.
|
|
68
|
+
* Keep unresolved records until their external outcome has been reconciled.
|
|
69
|
+
*/
|
|
70
|
+
export interface IExactIssuanceStore {
|
|
71
|
+
getAccountKey(accountId: string): Promise<string | null>;
|
|
72
|
+
createAccountKey(accountId: string, privateKey: string): Promise<boolean>;
|
|
73
|
+
getIssuance(certificateKey: string): Promise<IExactIssuanceRecord | null>;
|
|
74
|
+
compareAndSetIssuance(
|
|
75
|
+
certificateKey: string,
|
|
76
|
+
expectedRevision: number | null,
|
|
77
|
+
record: IExactIssuanceRecord,
|
|
78
|
+
): Promise<boolean>;
|
|
79
|
+
getCertificate(certificateKey: string): Promise<IExactCertificate | null>;
|
|
80
|
+
storeCertificate(certificate: IExactCertificate): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Safe status projection; contains neither keys nor challenge material. */
|
|
84
|
+
export interface IExactIssuanceInfo {
|
|
85
|
+
certificateKey: string;
|
|
86
|
+
issuanceId: string;
|
|
87
|
+
identifiers: string[];
|
|
88
|
+
status: TExactIssuanceStatus;
|
|
89
|
+
revision: number;
|
|
90
|
+
createdAt: number;
|
|
91
|
+
updatedAt: number;
|
|
92
|
+
errorCode?: TExactIssuanceErrorCode;
|
|
93
|
+
retryAfter?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type TExactCertificateResult =
|
|
97
|
+
| { status: 'ready'; certificate: IExactCertificate; issuance: IExactIssuanceInfo }
|
|
98
|
+
| { status: 'pending'; issuance: IExactIssuanceInfo };
|
|
99
|
+
|
|
100
|
+
export interface IExactIssuanceRecoveryRequest extends IExactCertificateRequest {
|
|
101
|
+
issuanceId: string;
|
|
102
|
+
expectedRevision: number;
|
|
103
|
+
action: 'recheck' | 'retry-proven-unapplied' | 'abandon';
|
|
104
|
+
}
|
package/ts/plugins.ts
CHANGED
|
@@ -3,10 +3,14 @@ import 'reflect-metadata';
|
|
|
3
3
|
|
|
4
4
|
// node native
|
|
5
5
|
import * as crypto from 'node:crypto';
|
|
6
|
+
import * as url from 'node:url';
|
|
7
|
+
import * as net from 'node:net';
|
|
8
|
+
import * as timers from 'node:timers/promises';
|
|
9
|
+
import * as x509 from '@peculiar/x509';
|
|
6
10
|
import * as fs from 'fs';
|
|
7
11
|
import * as path from 'path';
|
|
8
12
|
|
|
9
|
-
export { crypto, fs, path };
|
|
13
|
+
export { crypto, fs, path, url, net, timers, x509 };
|
|
10
14
|
|
|
11
15
|
// @apiclient.xyz scope
|
|
12
16
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|