Skip to content

Commit 3870e9a

Browse files
committed
update readme
2 parents acef31a + f37531a commit 3870e9a

42 files changed

Lines changed: 1827 additions & 167 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 300 additions & 65 deletions
Large diffs are not rendered by default.

src/Challenge/TlsAlpn01Handler.php

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
namespace CoyoteCert\Challenge;
4+
5+
use CoyoteCert\Enums\AuthorizationChallengeEnum;
6+
use CoyoteCert\Exceptions\ChallengeException;
7+
use CoyoteCert\Interfaces\ChallengeHandlerInterface;
8+
9+
/**
10+
* Base class for tls-alpn-01 challenge handlers (RFC 8737).
11+
*
12+
* The CA connects to port 443 of the domain being validated and negotiates the
13+
* "acme-tls/1" ALPN protocol. The server must present a self-signed certificate
14+
* that contains a critical id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31)
15+
* whose value is the SHA-256 digest of the key authorisation, encoded as a
16+
* DER OCTET STRING (RFC 8737 §3).
17+
*
18+
* Extend this class, implement deploy() and cleanup() to control how your TLS
19+
* server serves (and stops serving) the validation certificate. Call
20+
* generateAcmeCertificate() inside deploy() to obtain the cert and key.
21+
*
22+
* Example:
23+
*
24+
* class MyTlsAlpn01Handler extends TlsAlpn01Handler
25+
* {
26+
* public function deploy(string $domain, string $token, string $keyAuthorization): void
27+
* {
28+
* ['cert' => $certPem, 'key' => $keyPem] =
29+
* $this->generateAcmeCertificate($domain, $keyAuthorization);
30+
*
31+
* // Configure your web server to present $certPem/$keyPem for
32+
* // acme-tls/1 connections on port 443, then reload it.
33+
* MyServer::loadAcmeCert($domain, $certPem, $keyPem);
34+
* }
35+
*
36+
* public function cleanup(string $domain, string $token): void
37+
* {
38+
* MyServer::removeAcmeCert($domain);
39+
* }
40+
* }
41+
*/
42+
abstract class TlsAlpn01Handler implements ChallengeHandlerInterface
43+
{
44+
final public function supports(AuthorizationChallengeEnum $type): bool
45+
{
46+
return $type === AuthorizationChallengeEnum::TLS_ALPN;
47+
}
48+
49+
abstract public function deploy(string $domain, string $token, string $keyAuthorization): void;
50+
51+
abstract public function cleanup(string $domain, string $token): void;
52+
53+
/**
54+
* Generate the self-signed ACME validation certificate required by RFC 8737.
55+
*
56+
* Returns an array with keys 'cert' (PEM certificate) and 'key' (PEM private key).
57+
* Pass these to your TLS server so it can present the certificate to the CA on
58+
* port 443 when the "acme-tls/1" ALPN protocol is negotiated.
59+
*
60+
* @return array{cert: string, key: string}
61+
* @throws ChallengeException
62+
*/
63+
protected function generateAcmeCertificate(string $domain, string $keyAuthorization): array
64+
{
65+
// RFC 8737 §3: the extension value is SHA-256(keyAuthorization) as a DER OCTET STRING.
66+
$hash = hash('sha256', $keyAuthorization, true);
67+
$derBody = "\x04\x20" . $hash; // OCTET STRING tag (0x04), length 32 (0x20), value
68+
$hexDer = implode(':', str_split(bin2hex($derBody), 2));
69+
70+
$tempFile = tmpfile();
71+
72+
if ($tempFile === false) {
73+
throw new ChallengeException('Failed to create temporary config file for tls-alpn-01 certificate.');
74+
}
75+
76+
fwrite($tempFile, $this->buildConfig($domain, $hexDer));
77+
78+
$meta = stream_get_meta_data($tempFile);
79+
$uri = $meta['uri'] ?? null;
80+
81+
if ($uri === null) {
82+
fclose($tempFile);
83+
84+
throw new ChallengeException('Failed to obtain temporary config file path for tls-alpn-01 certificate.');
85+
}
86+
87+
try {
88+
return $this->generateCertAndKey($domain, $uri);
89+
} finally {
90+
fclose($tempFile);
91+
}
92+
}
93+
94+
/** @return array{cert: string, key: string} */
95+
private function generateCertAndKey(string $domain, string $configPath): array
96+
{
97+
// private_key_bits is ignored for EC but some openssl.cnf configs enforce a minimum.
98+
$key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1', 'private_key_bits' => 2048]);
99+
100+
if ($key === false) {
101+
throw new ChallengeException('Failed to generate EC key for tls-alpn-01 certificate.');
102+
}
103+
104+
$csr = openssl_csr_new(
105+
['commonName' => $domain],
106+
$key,
107+
['config' => $configPath, 'x509_extensions' => 'v3_acme'],
108+
);
109+
110+
if (!($csr instanceof \OpenSSLCertificateSigningRequest)) {
111+
throw new ChallengeException('Failed to generate CSR for tls-alpn-01 certificate.');
112+
}
113+
114+
$cert = openssl_csr_sign($csr, null, $key, 1, ['config' => $configPath, 'x509_extensions' => 'v3_acme']);
115+
116+
if ($cert === false) {
117+
throw new ChallengeException('Failed to sign tls-alpn-01 certificate.');
118+
}
119+
120+
if (!openssl_x509_export($cert, $certPem)) {
121+
throw new ChallengeException('Failed to export tls-alpn-01 certificate.');
122+
}
123+
124+
if (!openssl_pkey_export($key, $keyPem, null, ['config' => $configPath])) {
125+
throw new ChallengeException('Failed to export tls-alpn-01 private key.');
126+
}
127+
128+
return ['cert' => $certPem, 'key' => $keyPem];
129+
}
130+
131+
private function buildConfig(string $domain, string $hexDer): string
132+
{
133+
return sprintf(
134+
"[req]\ndistinguished_name=req_dn\nx509_extensions=v3_acme\nprompt=no\n\n"
135+
. "[req_dn]\nCN=%s\n\n"
136+
. "[v3_acme]\nsubjectAltName=DNS:%s\n1.3.6.1.5.5.7.1.31=critical,DER:%s\n",
137+
$domain,
138+
$domain,
139+
$hexDer,
140+
);
141+
}
142+
}

src/CoyoteCert.php

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use CoyoteCert\DTO\Http01ValidationData;
88
use CoyoteCert\DTO\OrderData;
99
use CoyoteCert\DTO\RenewalWindow;
10+
use CoyoteCert\DTO\TlsAlpn01ValidationData;
1011
use CoyoteCert\Enums\AuthorizationChallengeEnum;
1112
use CoyoteCert\Enums\KeyType;
1213
use CoyoteCert\Enums\RevocationReason;
@@ -18,6 +19,7 @@
1819
use CoyoteCert\Provider\AcmeProviderInterface;
1920
use CoyoteCert\Storage\StorageInterface;
2021
use CoyoteCert\Storage\StoredCertificate;
22+
use CoyoteCert\Support\CaaChecker;
2123
use CoyoteCert\Support\OpenSsl;
2224
use DateTimeImmutable;
2325
use 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
}

src/DTO/DomainValidationData.php

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* @param array<string, mixed> $file
1414
* @param array<string, mixed> $dns
1515
* @param array<string, mixed> $dnsPersist
16+
* @param array<string, mixed> $tlsAlpn
1617
* @param array<string, mixed> $validationRecord
1718
*/
1819
public function __construct(
@@ -22,7 +23,8 @@ public function __construct(
2223
public array $file,
2324
public array $dns,
2425
public array $dnsPersist,
25-
public array $validationRecord,
26+
public array $tlsAlpn = [],
27+
public array $validationRecord = [],
2628
) {}
2729

2830
public static function fromResponse(Response $response): DomainValidationData
@@ -37,6 +39,7 @@ public static function fromResponse(Response $response): DomainValidationData
3739
file: self::getValidationByType($challenges, AuthorizationChallengeEnum::HTTP),
3840
dns: self::getValidationByType($challenges, AuthorizationChallengeEnum::DNS),
3941
dnsPersist: self::getValidationByType($challenges, AuthorizationChallengeEnum::DNS_PERSIST),
42+
tlsAlpn: self::getValidationByType($challenges, AuthorizationChallengeEnum::TLS_ALPN),
4043
validationRecord: Arr::get($body, 'validationRecord', []),
4144
);
4245
}
@@ -72,7 +75,7 @@ public function isInvalid(): bool
7275

7376
public function hasErrors(): bool
7477
{
75-
foreach ([AuthorizationChallengeEnum::HTTP, AuthorizationChallengeEnum::DNS, AuthorizationChallengeEnum::DNS_PERSIST] as $type) {
78+
foreach ([AuthorizationChallengeEnum::HTTP, AuthorizationChallengeEnum::DNS, AuthorizationChallengeEnum::DNS_PERSIST, AuthorizationChallengeEnum::TLS_ALPN] as $type) {
7679
$data = $this->challengeData($type);
7780
if (!empty($data['error'])) {
7881
return true;
@@ -91,7 +94,7 @@ public function getErrors(): array
9194

9295
$errors = [];
9396

94-
foreach ([AuthorizationChallengeEnum::HTTP, AuthorizationChallengeEnum::DNS, AuthorizationChallengeEnum::DNS_PERSIST] as $type) {
97+
foreach ([AuthorizationChallengeEnum::HTTP, AuthorizationChallengeEnum::DNS, AuthorizationChallengeEnum::DNS_PERSIST, AuthorizationChallengeEnum::TLS_ALPN] as $type) {
9598
$data = $this->challengeData($type);
9699
if (!empty($data)) {
97100
$errors[] = [
@@ -111,6 +114,7 @@ public function challengeData(AuthorizationChallengeEnum $type): array
111114
AuthorizationChallengeEnum::HTTP => $this->file,
112115
AuthorizationChallengeEnum::DNS => $this->dns,
113116
AuthorizationChallengeEnum::DNS_PERSIST => $this->dnsPersist,
117+
AuthorizationChallengeEnum::TLS_ALPN => $this->tlsAlpn,
114118
};
115119
}
116120
}

0 commit comments

Comments
 (0)