@stacksjs/ts-cloud 0.1.7 → 0.1.9
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/aws/s3.d.ts +1 -1
- package/dist/bin/cli.js +223 -222
- package/dist/index.js +132 -132
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME Client for Let's Encrypt
|
|
3
|
+
* Implements RFC 8555 (ACME Protocol) for certificate issuance
|
|
4
|
+
*
|
|
5
|
+
* This is a pure TypeScript/Bun implementation without external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, createSign, generateKeyPairSync, randomBytes } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
// ACME Directory URLs
|
|
11
|
+
export const ACME_DIRECTORIES = {
|
|
12
|
+
production: 'https://acme-v02.api.letsencrypt.org/directory',
|
|
13
|
+
staging: 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
export interface AcmeClientOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Use staging server for testing
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
staging?: boolean
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Account email for Let's Encrypt notifications
|
|
25
|
+
*/
|
|
26
|
+
email: string
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Account key in PEM format (optional, will be generated if not provided)
|
|
30
|
+
*/
|
|
31
|
+
accountKey?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AcmeChallenge {
|
|
35
|
+
type: 'http-01' | 'dns-01'
|
|
36
|
+
token: string
|
|
37
|
+
keyAuthorization: string
|
|
38
|
+
/**
|
|
39
|
+
* For HTTP-01: URL path to serve the challenge
|
|
40
|
+
* For DNS-01: TXT record name
|
|
41
|
+
*/
|
|
42
|
+
identifier: string
|
|
43
|
+
/**
|
|
44
|
+
* For DNS-01: The value to put in the TXT record
|
|
45
|
+
*/
|
|
46
|
+
dnsValue?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AcmeCertificate {
|
|
50
|
+
certificate: string
|
|
51
|
+
privateKey: string
|
|
52
|
+
chain: string
|
|
53
|
+
fullchain: string
|
|
54
|
+
expiresAt: Date
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AcmeDirectory {
|
|
58
|
+
newNonce: string
|
|
59
|
+
newAccount: string
|
|
60
|
+
newOrder: string
|
|
61
|
+
revokeCert: string
|
|
62
|
+
keyChange: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* ACME Client for Let's Encrypt certificate management
|
|
67
|
+
*/
|
|
68
|
+
export class AcmeClient {
|
|
69
|
+
private directoryUrl: string
|
|
70
|
+
private email: string
|
|
71
|
+
private accountKey: string
|
|
72
|
+
private accountUrl: string | null = null
|
|
73
|
+
private directory: AcmeDirectory | null = null
|
|
74
|
+
private nonce: string | null = null
|
|
75
|
+
|
|
76
|
+
constructor(options: AcmeClientOptions) {
|
|
77
|
+
this.directoryUrl = options.staging
|
|
78
|
+
? ACME_DIRECTORIES.staging
|
|
79
|
+
: ACME_DIRECTORIES.production
|
|
80
|
+
this.email = options.email
|
|
81
|
+
this.accountKey = options.accountKey || this.generateAccountKey()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Generate a new account key pair
|
|
86
|
+
*/
|
|
87
|
+
private generateAccountKey(): string {
|
|
88
|
+
const { privateKey } = generateKeyPairSync('ec', {
|
|
89
|
+
namedCurve: 'P-256',
|
|
90
|
+
})
|
|
91
|
+
return privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the ACME directory
|
|
96
|
+
*/
|
|
97
|
+
private async getDirectory(): Promise<AcmeDirectory> {
|
|
98
|
+
if (this.directory) return this.directory
|
|
99
|
+
|
|
100
|
+
const response = await fetch(this.directoryUrl)
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new Error(`Failed to fetch ACME directory: ${response.status}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.directory = await response.json() as AcmeDirectory
|
|
106
|
+
return this.directory
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a fresh nonce for requests
|
|
111
|
+
*/
|
|
112
|
+
private async getNonce(): Promise<string> {
|
|
113
|
+
if (this.nonce) {
|
|
114
|
+
const nonce = this.nonce
|
|
115
|
+
this.nonce = null
|
|
116
|
+
return nonce
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const directory = await this.getDirectory()
|
|
120
|
+
const response = await fetch(directory.newNonce, { method: 'HEAD' })
|
|
121
|
+
const nonce = response.headers.get('replay-nonce')
|
|
122
|
+
|
|
123
|
+
if (!nonce) {
|
|
124
|
+
throw new Error('Failed to get nonce from ACME server')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return nonce
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create JWK from account key
|
|
132
|
+
*/
|
|
133
|
+
private getJwk(): Record<string, string> {
|
|
134
|
+
// Parse the EC private key to extract public key components
|
|
135
|
+
const keyLines = this.accountKey.split('\n')
|
|
136
|
+
.filter(line => !line.startsWith('-----'))
|
|
137
|
+
.join('')
|
|
138
|
+
|
|
139
|
+
// For EC P-256, we need to extract x and y coordinates
|
|
140
|
+
// This is a simplified version - in production you'd use a proper ASN.1 parser
|
|
141
|
+
const keyBuffer = Buffer.from(keyLines, 'base64')
|
|
142
|
+
|
|
143
|
+
// EC public key is the last 65 bytes (04 || x || y for uncompressed point)
|
|
144
|
+
// For P-256: 32 bytes for x, 32 bytes for y
|
|
145
|
+
const publicKeyStart = keyBuffer.length - 65
|
|
146
|
+
const x = keyBuffer.subarray(publicKeyStart + 1, publicKeyStart + 33)
|
|
147
|
+
const y = keyBuffer.subarray(publicKeyStart + 33, publicKeyStart + 65)
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
kty: 'EC',
|
|
151
|
+
crv: 'P-256',
|
|
152
|
+
x: this.base64UrlEncode(x),
|
|
153
|
+
y: this.base64UrlEncode(y),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Calculate JWK thumbprint
|
|
159
|
+
*/
|
|
160
|
+
private getJwkThumbprint(): string {
|
|
161
|
+
const jwk = this.getJwk()
|
|
162
|
+
// Canonical JSON for EC key
|
|
163
|
+
const canonical = JSON.stringify({
|
|
164
|
+
crv: jwk.crv,
|
|
165
|
+
kty: jwk.kty,
|
|
166
|
+
x: jwk.x,
|
|
167
|
+
y: jwk.y,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const hash = createHash('sha256').update(canonical).digest()
|
|
171
|
+
return this.base64UrlEncode(hash)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Base64URL encode
|
|
176
|
+
*/
|
|
177
|
+
private base64UrlEncode(data: Buffer | string): string {
|
|
178
|
+
const buffer = typeof data === 'string' ? Buffer.from(data) : data
|
|
179
|
+
return buffer.toString('base64')
|
|
180
|
+
.replace(/\+/g, '-')
|
|
181
|
+
.replace(/\//g, '_')
|
|
182
|
+
.replace(/=/g, '')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Sign a payload for ACME request
|
|
187
|
+
*/
|
|
188
|
+
private async signPayload(url: string, payload: any): Promise<string> {
|
|
189
|
+
const nonce = await this.getNonce()
|
|
190
|
+
const jwk = this.getJwk()
|
|
191
|
+
|
|
192
|
+
const protectedHeader: Record<string, any> = {
|
|
193
|
+
alg: 'ES256',
|
|
194
|
+
nonce,
|
|
195
|
+
url,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Use kid if we have an account URL, otherwise use jwk
|
|
199
|
+
if (this.accountUrl) {
|
|
200
|
+
protectedHeader.kid = this.accountUrl
|
|
201
|
+
} else {
|
|
202
|
+
protectedHeader.jwk = jwk
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const protectedB64 = this.base64UrlEncode(JSON.stringify(protectedHeader))
|
|
206
|
+
const payloadB64 = payload === ''
|
|
207
|
+
? ''
|
|
208
|
+
: this.base64UrlEncode(JSON.stringify(payload))
|
|
209
|
+
|
|
210
|
+
const signatureInput = `${protectedB64}.${payloadB64}`
|
|
211
|
+
|
|
212
|
+
const sign = createSign('SHA256')
|
|
213
|
+
sign.update(signatureInput)
|
|
214
|
+
const signature = sign.sign(this.accountKey)
|
|
215
|
+
|
|
216
|
+
// Convert DER signature to raw r||s format for ES256
|
|
217
|
+
const r = signature.subarray(4, 4 + signature[3])
|
|
218
|
+
const sStart = 4 + signature[3] + 2
|
|
219
|
+
const s = signature.subarray(sStart, sStart + signature[sStart - 1])
|
|
220
|
+
|
|
221
|
+
// Pad r and s to 32 bytes
|
|
222
|
+
const rPadded = Buffer.alloc(32)
|
|
223
|
+
const sPadded = Buffer.alloc(32)
|
|
224
|
+
r.copy(rPadded, 32 - r.length)
|
|
225
|
+
s.copy(sPadded, 32 - s.length)
|
|
226
|
+
|
|
227
|
+
const rawSignature = Buffer.concat([rPadded, sPadded])
|
|
228
|
+
|
|
229
|
+
return JSON.stringify({
|
|
230
|
+
protected: protectedB64,
|
|
231
|
+
payload: payloadB64,
|
|
232
|
+
signature: this.base64UrlEncode(rawSignature),
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Make a signed ACME request
|
|
238
|
+
*/
|
|
239
|
+
private async acmeRequest(url: string, payload: any): Promise<{ body: any; headers: Headers }> {
|
|
240
|
+
const body = await this.signPayload(url, payload)
|
|
241
|
+
|
|
242
|
+
const response = await fetch(url, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: {
|
|
245
|
+
'Content-Type': 'application/jose+json',
|
|
246
|
+
},
|
|
247
|
+
body,
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// Store the new nonce for the next request
|
|
251
|
+
const newNonce = response.headers.get('replay-nonce')
|
|
252
|
+
if (newNonce) {
|
|
253
|
+
this.nonce = newNonce
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let responseBody: any
|
|
257
|
+
const contentType = response.headers.get('content-type')
|
|
258
|
+
if (contentType?.includes('application/json') || contentType?.includes('application/problem+json')) {
|
|
259
|
+
responseBody = await response.json()
|
|
260
|
+
} else {
|
|
261
|
+
responseBody = await response.text()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
throw new Error(`ACME request failed: ${JSON.stringify(responseBody)}`)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { body: responseBody, headers: response.headers }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Register or get existing account
|
|
273
|
+
*/
|
|
274
|
+
async registerAccount(): Promise<string> {
|
|
275
|
+
const directory = await this.getDirectory()
|
|
276
|
+
|
|
277
|
+
const { body, headers } = await this.acmeRequest(directory.newAccount, {
|
|
278
|
+
termsOfServiceAgreed: true,
|
|
279
|
+
contact: [`mailto:${this.email}`],
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const location = headers.get('location')
|
|
283
|
+
if (!location) {
|
|
284
|
+
throw new Error('No account URL returned')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.accountUrl = location
|
|
288
|
+
return location
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Create a new certificate order
|
|
293
|
+
*/
|
|
294
|
+
async createOrder(domains: string[]): Promise<{
|
|
295
|
+
orderUrl: string
|
|
296
|
+
authorizations: string[]
|
|
297
|
+
finalize: string
|
|
298
|
+
}> {
|
|
299
|
+
if (!this.accountUrl) {
|
|
300
|
+
await this.registerAccount()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const directory = await this.getDirectory()
|
|
304
|
+
|
|
305
|
+
const { body, headers } = await this.acmeRequest(directory.newOrder, {
|
|
306
|
+
identifiers: domains.map(domain => ({
|
|
307
|
+
type: 'dns',
|
|
308
|
+
value: domain,
|
|
309
|
+
})),
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const orderUrl = headers.get('location')
|
|
313
|
+
if (!orderUrl) {
|
|
314
|
+
throw new Error('No order URL returned')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
orderUrl,
|
|
319
|
+
authorizations: body.authorizations,
|
|
320
|
+
finalize: body.finalize,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Get authorization challenges
|
|
326
|
+
*/
|
|
327
|
+
async getAuthorization(authUrl: string): Promise<{
|
|
328
|
+
domain: string
|
|
329
|
+
challenges: AcmeChallenge[]
|
|
330
|
+
}> {
|
|
331
|
+
const { body } = await this.acmeRequest(authUrl, '')
|
|
332
|
+
|
|
333
|
+
const domain = body.identifier.value
|
|
334
|
+
const thumbprint = this.getJwkThumbprint()
|
|
335
|
+
|
|
336
|
+
const challenges: AcmeChallenge[] = body.challenges
|
|
337
|
+
.filter((c: any) => c.type === 'http-01' || c.type === 'dns-01')
|
|
338
|
+
.map((c: any) => {
|
|
339
|
+
const keyAuthorization = `${c.token}.${thumbprint}`
|
|
340
|
+
|
|
341
|
+
if (c.type === 'http-01') {
|
|
342
|
+
return {
|
|
343
|
+
type: 'http-01' as const,
|
|
344
|
+
token: c.token,
|
|
345
|
+
keyAuthorization,
|
|
346
|
+
identifier: `/.well-known/acme-challenge/${c.token}`,
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
// DNS-01: TXT record value is base64url(sha256(keyAuthorization))
|
|
350
|
+
const dnsValue = this.base64UrlEncode(
|
|
351
|
+
createHash('sha256').update(keyAuthorization).digest()
|
|
352
|
+
)
|
|
353
|
+
return {
|
|
354
|
+
type: 'dns-01' as const,
|
|
355
|
+
token: c.token,
|
|
356
|
+
keyAuthorization,
|
|
357
|
+
identifier: `_acme-challenge.${domain}`,
|
|
358
|
+
dnsValue,
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
return { domain, challenges }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Respond to a challenge (tell ACME server we're ready)
|
|
368
|
+
*/
|
|
369
|
+
async respondToChallenge(challengeUrl: string): Promise<void> {
|
|
370
|
+
await this.acmeRequest(challengeUrl, {})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Poll for authorization status
|
|
375
|
+
*/
|
|
376
|
+
async waitForAuthorization(authUrl: string, maxAttempts = 30): Promise<void> {
|
|
377
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
378
|
+
const { body } = await this.acmeRequest(authUrl, '')
|
|
379
|
+
|
|
380
|
+
if (body.status === 'valid') {
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (body.status === 'invalid') {
|
|
385
|
+
throw new Error(`Authorization failed: ${JSON.stringify(body)}`)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await new Promise(resolve => setTimeout(resolve, 2000))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
throw new Error('Authorization timed out')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Generate a CSR (Certificate Signing Request)
|
|
396
|
+
*/
|
|
397
|
+
private generateCsr(domains: string[]): { csr: string; privateKey: string } {
|
|
398
|
+
// Generate a new key pair for the certificate
|
|
399
|
+
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
|
|
400
|
+
modulusLength: 2048,
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
|
|
404
|
+
|
|
405
|
+
// For a proper CSR, we'd need to use a library like node-forge
|
|
406
|
+
// This is a simplified placeholder - in production, use proper CSR generation
|
|
407
|
+
// For now, we'll create a minimal CSR structure
|
|
408
|
+
|
|
409
|
+
// The CSR needs proper ASN.1 encoding with the domains in Subject Alternative Names
|
|
410
|
+
// This requires either a native module or a library like node-forge
|
|
411
|
+
|
|
412
|
+
// Placeholder: In a real implementation, generate proper CSR
|
|
413
|
+
const csrPlaceholder = this.createSimpleCsr(domains, privateKey as any)
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
csr: csrPlaceholder,
|
|
417
|
+
privateKey: privateKeyPem,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Create a simple CSR (placeholder - would need proper implementation)
|
|
423
|
+
*/
|
|
424
|
+
private createSimpleCsr(domains: string[], privateKey: any): string {
|
|
425
|
+
// This is a placeholder. A real implementation would:
|
|
426
|
+
// 1. Create proper ASN.1 structure for CSR
|
|
427
|
+
// 2. Include Subject Alternative Names for all domains
|
|
428
|
+
// 3. Sign with the private key
|
|
429
|
+
|
|
430
|
+
// For production, use a library like @peculiar/x509 or node-forge
|
|
431
|
+
throw new Error(
|
|
432
|
+
'CSR generation requires additional implementation. ' +
|
|
433
|
+
'Consider using the shell-based certbot approach instead.'
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Finalize the order and get the certificate
|
|
439
|
+
*/
|
|
440
|
+
async finalizeOrder(finalizeUrl: string, domains: string[]): Promise<AcmeCertificate> {
|
|
441
|
+
const { csr, privateKey } = this.generateCsr(domains)
|
|
442
|
+
|
|
443
|
+
const { body } = await this.acmeRequest(finalizeUrl, {
|
|
444
|
+
csr: this.base64UrlEncode(Buffer.from(csr, 'base64')),
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// Poll for certificate
|
|
448
|
+
let certificateUrl = body.certificate
|
|
449
|
+
if (!certificateUrl) {
|
|
450
|
+
// Need to poll the order
|
|
451
|
+
throw new Error('Certificate not immediately available - polling not implemented')
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Download certificate
|
|
455
|
+
const certResponse = await fetch(certificateUrl)
|
|
456
|
+
const fullchain = await certResponse.text()
|
|
457
|
+
|
|
458
|
+
// Split fullchain into certificate and chain
|
|
459
|
+
const certs = fullchain.split(/(?=-----BEGIN CERTIFICATE-----)/g)
|
|
460
|
+
const certificate = certs[0]
|
|
461
|
+
const chain = certs.slice(1).join('')
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
certificate,
|
|
465
|
+
privateKey,
|
|
466
|
+
chain,
|
|
467
|
+
fullchain,
|
|
468
|
+
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), // 90 days
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Get the account key (for storage/reuse)
|
|
474
|
+
*/
|
|
475
|
+
getAccountKey(): string {
|
|
476
|
+
return this.accountKey
|
|
477
|
+
}
|
|
478
|
+
}
|