Skip to content

Commit ea1d469

Browse files
committed
Add preferred chain selection via RFC 8555 §7.4.2 alternate links
1 parent 7e07d32 commit ea1d469

5 files changed

Lines changed: 260 additions & 4 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ echo $cert->caBundle; // PEM intermediate chain
126126
- [Automatic renewal](#automatic-renewal)
127127
- [ARI: CA-guided renewal windows](#ari-ca-guided-renewal-windows)
128128
- [ACME profiles](#acme-profiles)
129+
- [Preferred chain selection](#preferred-chain-selection)
129130
- [Key types](#key-types)
130131
- [Certificate revocation](#certificate-revocation)
131132
- [PSR-18 HTTP client](#psr-18-http-client)
@@ -701,6 +702,31 @@ Profiles are forwarded to the CA only if the provider reports `supportsProfiles(
701702

702703
---
703704

705+
## Preferred chain selection
706+
707+
Some CAs offer multiple certificate chains via `Link: rel="alternate"` headers (RFC 8555 §7.4.2). Let's Encrypt uses this to serve both the ISRG Root X1 chain and older cross-signed chains.
708+
709+
Use `->preferredChain()` to request a specific chain by matching against the Common Name or Organisation of the intermediate certificates. The match is a case-insensitive substring, so partial names work fine.
710+
711+
```php
712+
// Prefer the ISRG Root X1 chain (shorter, no DST cross-signature)
713+
CoyoteCert::with(new LetsEncrypt())
714+
->identifiers('example.com')
715+
->challenge(new Http01Handler('/var/www/html'))
716+
->preferredChain('ISRG Root X1')
717+
->issueOrRenew();
718+
```
719+
720+
If no alternate chain matches, CoyoteCert falls back to the default chain returned by the CA — so this call is always safe to include even when the CA offers only one chain.
721+
722+
When using the low-level API directly, pass the preference as a second argument to `getBundle()`:
723+
724+
```php
725+
$bundle = $api->certificate()->getBundle($order, 'ISRG Root X1');
726+
```
727+
728+
---
729+
704730
## Key types
705731

706732
```php
@@ -892,6 +918,7 @@ CoyoteCert::with(AcmeProviderInterface $provider) // factory — select the CA
892918
| `->httpClient(ClientInterface, ...)` | fluent | built-in curl | PSR-18 HTTP client |
893919
| `->withHttpTimeout(int)` | fluent | `10` | Curl timeout in seconds |
894920
| `->logger(LoggerInterface)` | fluent | none | PSR-3 logger |
921+
| `->preferredChain(string)` | fluent | `''` | Preferred chain issuer CN/O (RFC 8555 §7.4.2); falls back to default chain if no match |
895922
| `->skipLocalTest()` | fluent | off | Disable pre-flight HTTP/DNS self-check |
896923
| `->skipCaaCheck()` | fluent | off | Disable CAA DNS pre-check (internal CAs, split-horizon DNS) |
897924
| `->issue()` | terminal || Issue unconditionally; returns `StoredCertificate` |

src/CoyoteCert.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class CoyoteCert
5757
private KeyType $accountKeyType = KeyType::EC_P256;
5858
private bool $localTest = true;
5959
private bool $skipCaaCheck = false;
60+
private string $preferredChain = '';
6061

6162
private function __construct(private readonly AcmeProviderInterface $provider) {}
6263

@@ -180,6 +181,23 @@ public function skipCaaCheck(): self
180181
return $this;
181182
}
182183

184+
/**
185+
* Prefer a specific certificate chain by matching the issuer Common Name or
186+
* Organisation of the intermediate certificates (RFC 8555 §7.4.2).
187+
*
188+
* When the CA offers alternate chains via Link: rel="alternate" headers, the
189+
* first chain whose intermediates contain $issuer (case-insensitive substring)
190+
* is returned. Falls back to the default chain when no match is found.
191+
*
192+
* Example: ->preferredChain('ISRG Root X1')
193+
*/
194+
public function preferredChain(string $issuer): self
195+
{
196+
$this->preferredChain = $issuer;
197+
198+
return $this;
199+
}
200+
183201
/**
184202
* Set the HTTP timeout in seconds for the built-in curl client.
185203
* No-op when a custom PSR-18 client is configured.
@@ -335,7 +353,7 @@ private function fetchAndStoreCertificate(
335353
}
336354

337355
$order = $api->order()->waitUntilValid($order);
338-
$bundle = $api->certificate()->getBundle($order);
356+
$bundle = $api->certificate()->getBundle($order, $this->preferredChain ?: null);
339357

340358
$parsed = openssl_x509_parse($bundle->certificate);
341359
$expiresAt = isset($parsed['validTo_time_t'])

src/Endpoints/Certificate.php

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
class Certificate extends Endpoint
1111
{
12-
public function getBundle(OrderData $orderData): CertificateBundleData
12+
public function getBundle(OrderData $orderData, ?string $preferredChain = null): CertificateBundleData
1313
{
1414
if ($orderData->certificateUrl === null) {
1515
throw new AcmeException('Order does not have a certificate URL yet.');
@@ -23,7 +23,71 @@ public function getBundle(OrderData $orderData): CertificateBundleData
2323
throw new AcmeException('Failed to fetch certificate.');
2424
}
2525

26-
return CertificateBundleData::fromResponse($response);
26+
$primary = CertificateBundleData::fromResponse($response);
27+
28+
if ($preferredChain === null) {
29+
return $primary;
30+
}
31+
32+
$linkHeader = $response->getHeader('link', '');
33+
$linkHeader = is_string($linkHeader) ? $linkHeader : '';
34+
35+
foreach ($this->parseAlternateLinks($linkHeader) as $url) {
36+
$altResponse = $this->postSigned($url, $orderData->accountUrl);
37+
38+
if ($altResponse->getHttpResponseCode() !== 200) {
39+
continue;
40+
}
41+
42+
$candidate = CertificateBundleData::fromResponse($altResponse);
43+
44+
if ($this->chainMatchesIssuer($candidate, $preferredChain)) {
45+
return $candidate;
46+
}
47+
}
48+
49+
return $primary;
50+
}
51+
52+
/** @return string[] */
53+
private function parseAlternateLinks(string $linkHeader): array
54+
{
55+
if ($linkHeader === '') {
56+
return [];
57+
}
58+
59+
$urls = [];
60+
61+
foreach (explode(',', $linkHeader) as $entry) {
62+
if (preg_match('~<([^>]+)>\s*;\s*rel\s*=\s*["\']?alternate["\']?~i', trim($entry), $m)) {
63+
$urls[] = $m[1];
64+
}
65+
}
66+
67+
return $urls;
68+
}
69+
70+
private function chainMatchesIssuer(CertificateBundleData $bundle, string $preferredChain): bool
71+
{
72+
if ($bundle->caBundle === '') {
73+
return false;
74+
}
75+
76+
if (!preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $matches)) {
77+
return false;
78+
}
79+
80+
foreach ($matches[1] as $certPem) {
81+
$parsed = openssl_x509_parse($certPem);
82+
$cn = $parsed['subject']['CN'] ?? '';
83+
$o = $parsed['subject']['O'] ?? '';
84+
85+
if (stripos($cn, $preferredChain) !== false || stripos($o, $preferredChain) !== false) {
86+
return true;
87+
}
88+
}
89+
90+
return false;
2791
}
2892

2993
public function revoke(string $pem, int $reason = 0): bool

src/Http/Client.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,16 @@ private function parseRawHeaders(string $rawHeaders): array
164164

165165
[$name, $value] = explode(':', $header, 2);
166166

167-
$headersArr[str_replace('_', '-', strtolower($name))] = trim($value);
167+
$key = str_replace('_', '-', strtolower($name));
168+
$trimmedValue = trim($value);
169+
170+
// Accumulate repeated headers (e.g. multiple Link: rel="alternate")
171+
// as comma-separated, consistent with PSR-7 multi-value behaviour.
172+
if (isset($headersArr[$key])) {
173+
$headersArr[$key] .= ', ' . $trimmedValue;
174+
} else {
175+
$headersArr[$key] = $trimmedValue;
176+
}
168177
}
169178

170179
return $headersArr;

tests/Unit/Endpoints/EndpointsTest.php

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,144 @@ function directoryBodyWithKeyChange(bool $withRenewalInfo = false): array
13751375
expect($callCount)->toBe(2); // pending → valid after one retry
13761376
});
13771377

1378+
// ── Certificate::getBundle() preferred chain selection ────────────────────────
1379+
1380+
function makeCertBundle(string $issuerCN = 'Test Issuer CA'): string
1381+
{
1382+
$issuerKey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
1383+
$issuerCsr = openssl_csr_new(['commonName' => $issuerCN], $issuerKey);
1384+
$issuerCert = openssl_csr_sign($issuerCsr, null, $issuerKey, 3650, serial: 2);
1385+
openssl_x509_export($issuerCert, $issuerPem);
1386+
1387+
$leafKey = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_RSA, 'private_key_bits' => 2048]);
1388+
$leafCsr = openssl_csr_new(['commonName' => 'leaf.example.com'], $leafKey);
1389+
$leafCert = openssl_csr_sign($leafCsr, $issuerCert, $issuerKey, 365, serial: 3);
1390+
openssl_x509_export($leafCert, $leafPem);
1391+
1392+
return $leafPem . "\n" . $issuerPem;
1393+
}
1394+
1395+
function certOrderData(): OrderData
1396+
{
1397+
return new OrderData(
1398+
id: '1',
1399+
url: 'https://acme.example/order/1',
1400+
status: 'valid',
1401+
expires: '2099-01-01T00:00:00Z',
1402+
identifiers: [],
1403+
domainValidationUrls: [],
1404+
finalizeUrl: 'https://acme.example/finalize/1',
1405+
accountUrl: 'https://acme.example/account/1',
1406+
certificateUrl: 'https://acme.example/cert/1',
1407+
finalized: true,
1408+
);
1409+
}
1410+
1411+
it('Certificate::getBundle() returns preferred alternate chain when issuer CN matches', function () {
1412+
$storage = withKeyStorage();
1413+
$primaryBundle = makeCertBundle('Default Root CA');
1414+
$alternateBundle = makeCertBundle('ISRG Root X1');
1415+
1416+
$mock = closureMock(
1417+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
1418+
postHandler: function ($url) use ($primaryBundle, $alternateBundle) {
1419+
if (str_contains($url, '/alt')) {
1420+
return new Response([], $url, 200, $alternateBundle);
1421+
}
1422+
1423+
return new Response(
1424+
['link' => '<https://acme.example/cert/1/alt>;rel="alternate"'],
1425+
$url,
1426+
200,
1427+
$primaryBundle,
1428+
);
1429+
},
1430+
);
1431+
1432+
$bundle = makeEndpointApi($mock, $storage)->certificate()->getBundle(certOrderData(), 'ISRG Root X1');
1433+
1434+
preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $m);
1435+
$parsed = openssl_x509_parse($m[1][0]);
1436+
expect($parsed['subject']['CN'])->toBe('ISRG Root X1');
1437+
});
1438+
1439+
it('Certificate::getBundle() falls back to primary when no alternate chain matches preferred issuer', function () {
1440+
$storage = withKeyStorage();
1441+
$primaryBundle = makeCertBundle('Default Root CA');
1442+
$alternateBundle = makeCertBundle('Some Other CA');
1443+
1444+
$mock = closureMock(
1445+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
1446+
postHandler: function ($url) use ($primaryBundle, $alternateBundle) {
1447+
if (str_contains($url, '/alt')) {
1448+
return new Response([], $url, 200, $alternateBundle);
1449+
}
1450+
1451+
return new Response(
1452+
['link' => '<https://acme.example/cert/1/alt>;rel="alternate"'],
1453+
$url,
1454+
200,
1455+
$primaryBundle,
1456+
);
1457+
},
1458+
);
1459+
1460+
$bundle = makeEndpointApi($mock, $storage)->certificate()->getBundle(certOrderData(), 'ISRG Root X1');
1461+
1462+
preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $m);
1463+
$parsed = openssl_x509_parse($m[1][0]);
1464+
expect($parsed['subject']['CN'])->toBe('Default Root CA');
1465+
});
1466+
1467+
it('Certificate::getBundle() returns primary chain when no Link header is present with preferredChain set', function () {
1468+
$storage = withKeyStorage();
1469+
$primaryBundle = makeCertBundle('Default Root CA');
1470+
1471+
$mock = closureMock(
1472+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
1473+
postHandler: fn($url) => new Response([], $url, 200, $primaryBundle),
1474+
);
1475+
1476+
$bundle = makeEndpointApi($mock, $storage)->certificate()->getBundle(certOrderData(), 'ISRG Root X1');
1477+
1478+
preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $m);
1479+
$parsed = openssl_x509_parse($m[1][0]);
1480+
expect($parsed['subject']['CN'])->toBe('Default Root CA');
1481+
});
1482+
1483+
it('Certificate::getBundle() handles multiple Link headers (comma-separated) and selects matching chain', function () {
1484+
$storage = withKeyStorage();
1485+
$primaryBundle = makeCertBundle('Default Root CA');
1486+
$alternate1Bundle = makeCertBundle('Other CA');
1487+
$alternate2Bundle = makeCertBundle('ISRG Root X1');
1488+
1489+
$mock = closureMock(
1490+
getHandler: fn($url) => new Response([], $url, 200, directoryBody()),
1491+
postHandler: function ($url) use ($primaryBundle, $alternate1Bundle, $alternate2Bundle) {
1492+
if (str_contains($url, '/alt2')) {
1493+
return new Response([], $url, 200, $alternate2Bundle);
1494+
}
1495+
1496+
if (str_contains($url, '/alt1')) {
1497+
return new Response([], $url, 200, $alternate1Bundle);
1498+
}
1499+
1500+
return new Response(
1501+
['link' => '<https://acme.example/cert/1/alt1>;rel="alternate", <https://acme.example/cert/1/alt2>;rel="alternate"'],
1502+
$url,
1503+
200,
1504+
$primaryBundle,
1505+
);
1506+
},
1507+
);
1508+
1509+
$bundle = makeEndpointApi($mock, $storage)->certificate()->getBundle(certOrderData(), 'ISRG Root X1');
1510+
1511+
preg_match_all('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~i', $bundle->caBundle, $m);
1512+
$parsed = openssl_x509_parse($m[1][0]);
1513+
expect($parsed['subject']['CN'])->toBe('ISRG Root X1');
1514+
});
1515+
13781516
// ── Certificate::getBundle() null certificateUrl path (line 15) ──────────────
13791517

13801518
it('Certificate::getBundle() throws AcmeException when certificateUrl is null', function () {

0 commit comments

Comments
 (0)