77use CoyoteCert \DTO \Http01ValidationData ;
88use CoyoteCert \DTO \OrderData ;
99use CoyoteCert \DTO \RenewalWindow ;
10+ use CoyoteCert \DTO \TlsAlpn01ValidationData ;
1011use CoyoteCert \Enums \AuthorizationChallengeEnum ;
1112use CoyoteCert \Enums \KeyType ;
1213use CoyoteCert \Enums \RevocationReason ;
1819use CoyoteCert \Provider \AcmeProviderInterface ;
1920use CoyoteCert \Storage \StorageInterface ;
2021use CoyoteCert \Storage \StoredCertificate ;
22+ use CoyoteCert \Support \CaaChecker ;
2123use CoyoteCert \Support \OpenSsl ;
2224use DateTimeImmutable ;
2325use Psr \Log \LoggerInterface ;
@@ -54,6 +56,12 @@ class CoyoteCert
5456 private KeyType $ certKeyType = KeyType::EC_P256 ;
5557 private KeyType $ accountKeyType = KeyType::EC_P256 ;
5658 private bool $ localTest = true ;
59+ private bool $ skipCaaCheck = false ;
60+ private string $ preferredChain = '' ;
61+ /** @var callable[] */
62+ private array $ onIssuedCallbacks = [];
63+ /** @var callable[] */
64+ private array $ onRenewedCallbacks = [];
5765
5866 private function __construct (private readonly AcmeProviderInterface $ provider ) {}
5967
@@ -166,6 +174,59 @@ public function skipLocalTest(): self
166174 return $ this ;
167175 }
168176
177+ /**
178+ * Skip the CAA DNS pre-check before submitting the order to the CA.
179+ * Useful when DNS is internal or the CAA records are managed outside your control.
180+ */
181+ public function skipCaaCheck (): self
182+ {
183+ $ this ->skipCaaCheck = true ;
184+
185+ return $ this ;
186+ }
187+
188+ /**
189+ * Prefer a specific certificate chain by matching the issuer Common Name or
190+ * Organisation of the intermediate certificates (RFC 8555 §7.4.2).
191+ *
192+ * When the CA offers alternate chains via Link: rel="alternate" headers, the
193+ * first chain whose intermediates contain $issuer (case-insensitive substring)
194+ * is returned. Falls back to the default chain when no match is found.
195+ *
196+ * Example: ->preferredChain('ISRG Root X1')
197+ */
198+ public function preferredChain (string $ issuer ): self
199+ {
200+ $ this ->preferredChain = $ issuer ;
201+
202+ return $ this ;
203+ }
204+
205+ /**
206+ * Register a callback invoked after every successful certificate issuance.
207+ * The callback receives the issued StoredCertificate as its sole argument.
208+ * Multiple callbacks may be registered; they run in registration order.
209+ */
210+ public function onIssued (callable $ callback ): self
211+ {
212+ $ this ->onIssuedCallbacks [] = $ callback ;
213+
214+ return $ this ;
215+ }
216+
217+ /**
218+ * Register a callback invoked after a certificate is renewed (i.e. an existing
219+ * certificate was replaced). Fires in addition to onIssued callbacks.
220+ * The callback receives the new StoredCertificate as its sole argument.
221+ * Multiple callbacks may be registered; they run in registration order.
222+ */
223+ public function onRenewed (callable $ callback ): self
224+ {
225+ $ this ->onRenewedCallbacks [] = $ callback ;
226+
227+ return $ this ;
228+ }
229+
169230 /**
170231 * Set the HTTP timeout in seconds for the built-in curl client.
171232 * No-op when a custom PSR-18 client is configured.
@@ -195,7 +256,7 @@ public function needsRenewal(int $daysBeforeExpiry = 30): bool
195256 return true ;
196257 }
197258
198- $ cert = $ this ->storage ->getCertificate ($ this ->domains [0 ]);
259+ $ cert = $ this ->storage ->getCertificate ($ this ->domains [0 ], $ this -> certKeyType );
199260
200261 if ($ cert === null ) {
201262 return true ;
@@ -219,6 +280,17 @@ public function issue(): StoredCertificate
219280 {
220281 $ this ->validate ();
221282
283+ if (!$ this ->skipCaaCheck ) {
284+ $ domainIdentifiers = array_values (array_filter (
285+ $ this ->domains ,
286+ static fn (string $ id ): bool => !filter_var ($ id , FILTER_VALIDATE_IP ),
287+ ));
288+
289+ if (!empty ($ domainIdentifiers )) {
290+ (new CaaChecker ())->check ($ domainIdentifiers , $ this ->provider ->getCaaIdentifiers ());
291+ }
292+ }
293+
222294 $ challengeHandler = $ this ->challengeHandler ;
223295
224296 if ($ challengeHandler === null ) {
@@ -236,7 +308,7 @@ public function issue(): StoredCertificate
236308 $ account = $ this ->getOrCreateAccount ($ api );
237309
238310 $ replacesId = '' ;
239- $ existingCert = $ this ->storage ?->getCertificate($ this ->domains [0 ]);
311+ $ existingCert = $ this ->storage ?->getCertificate($ this ->domains [0 ], $ this -> certKeyType );
240312 if ($ existingCert !== null && ($ issuerPem = $ this ->extractIssuerPem ($ existingCert )) !== null ) {
241313 try {
242314 $ replacesId = $ api ->renewalInfo ()->certId ($ existingCert ->certificate , $ issuerPem );
@@ -252,7 +324,10 @@ public function issue(): StoredCertificate
252324 // Refresh order — status transitions pending → ready after all challenges pass
253325 $ order = $ api ->order ()->refresh ($ order );
254326
255- return $ this ->fetchAndStoreCertificate ($ api , $ order );
327+ $ stored = $ this ->fetchAndStoreCertificate ($ api , $ order );
328+ $ this ->fireIssuedCallbacks ($ stored , isRenewal: $ existingCert !== null );
329+
330+ return $ stored ;
256331 }
257332
258333 // ── Private issue() helpers ───────────────────────────────────────────────
@@ -310,7 +385,7 @@ private function fetchAndStoreCertificate(
310385 }
311386
312387 $ order = $ api ->order ()->waitUntilValid ($ order );
313- $ bundle = $ api ->certificate ()->getBundle ($ order );
388+ $ bundle = $ api ->certificate ()->getBundle ($ order, $ this -> preferredChain ?: null );
314389
315390 $ parsed = openssl_x509_parse ($ bundle ->certificate );
316391 $ expiresAt = isset ($ parsed ['validTo_time_t ' ])
@@ -325,6 +400,7 @@ private function fetchAndStoreCertificate(
325400 issuedAt: new DateTimeImmutable (),
326401 expiresAt: $ expiresAt ,
327402 domains: $ this ->domains ,
403+ keyType: $ this ->certKeyType ,
328404 );
329405
330406 if ($ this ->storage !== null ) {
@@ -334,6 +410,19 @@ private function fetchAndStoreCertificate(
334410 return $ stored ;
335411 }
336412
413+ private function fireIssuedCallbacks (StoredCertificate $ cert , bool $ isRenewal ): void
414+ {
415+ foreach ($ this ->onIssuedCallbacks as $ cb ) {
416+ $ cb ($ cert );
417+ }
418+
419+ if ($ isRenewal ) {
420+ foreach ($ this ->onRenewedCallbacks as $ cb ) {
421+ $ cb ($ cert );
422+ }
423+ }
424+ }
425+
337426 /**
338427 * Alias for issue() — forces a fresh certificate regardless of expiry.
339428 */
@@ -378,7 +467,7 @@ public function issueOrRenew(int $daysBeforeExpiry = 30): StoredCertificate
378467 throw new AcmeException ('Certificate unexpectedly missing from storage. ' );
379468 }
380469
381- return $ this ->storage ->getCertificate ($ this ->domains [0 ])
470+ return $ this ->storage ->getCertificate ($ this ->domains [0 ], $ this -> certKeyType )
382471 ?? throw new AcmeException ('Certificate unexpectedly missing from storage. ' );
383472 }
384473
@@ -467,15 +556,19 @@ private function detectChallengeType(): AuthorizationChallengeEnum
467556 /**
468557 * Returns [token, keyAuthorization] from a typed validation DTO.
469558 *
470- * @param Http01ValidationData|Dns01ValidationData $item
559+ * @param Http01ValidationData|Dns01ValidationData|TlsAlpn01ValidationData $item
471560 * @return array{0: string, 1: string}
472561 */
473- private function extractTokenAndKeyAuth (Http01ValidationData |Dns01ValidationData $ item ): array
562+ private function extractTokenAndKeyAuth (Http01ValidationData |Dns01ValidationData | TlsAlpn01ValidationData $ item ): array
474563 {
475564 if ($ item instanceof Http01ValidationData) {
476565 return [$ item ->filename , $ item ->content ];
477566 }
478567
568+ if ($ item instanceof TlsAlpn01ValidationData) {
569+ return [$ item ->token , $ item ->keyAuthorization ];
570+ }
571+
479572 return [$ item ->name , $ item ->value ];
480573 }
481574}
0 commit comments