Skip to content

Commit d3778d1

Browse files
authored
Merge pull request #5 from blendbyte/preferred-chain-selection
Add preferred chain selection (RFC 8555 §7.4.2)
2 parents 3c183d2 + ca83e9e commit d3778d1

6 files changed

Lines changed: 333 additions & 4 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ echo $cert->caBundle; // PEM intermediate chain
131131
- [Automatic renewal](#automatic-renewal)
132132
- [ARI: CA-guided renewal windows](#ari-ca-guided-renewal-windows)
133133
- [ACME profiles](#acme-profiles)
134+
- [Preferred chain selection](#preferred-chain-selection)
134135
- [Key types](#key-types)
135136
- [Certificate revocation](#certificate-revocation)
136137
- [PSR-18 HTTP client](#psr-18-http-client)
@@ -820,6 +821,31 @@ Profiles are forwarded to the CA only if the provider reports `supportsProfiles(
820821

821822
---
822823

824+
## Preferred chain selection
825+
826+
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.
827+
828+
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.
829+
830+
```php
831+
// Prefer the ISRG Root X1 chain (shorter, no DST cross-signature)
832+
CoyoteCert::with(new LetsEncrypt())
833+
->identifiers('example.com')
834+
->challenge(new Http01Handler('/var/www/html'))
835+
->preferredChain('ISRG Root X1')
836+
->issueOrRenew();
837+
```
838+
839+
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.
840+
841+
When using the low-level API directly, pass the preference as a second argument to `getBundle()`:
842+
843+
```php
844+
$bundle = $api->certificate()->getBundle($order, 'ISRG Root X1');
845+
```
846+
847+
---
848+
823849
## Key types
824850

825851
```php
@@ -1011,6 +1037,7 @@ CoyoteCert::with(AcmeProviderInterface $provider) // factory — select the CA
10111037
| `->httpClient(ClientInterface, ...)` | fluent | built-in curl | PSR-18 HTTP client |
10121038
| `->withHttpTimeout(int)` | fluent | `10` | Curl timeout in seconds |
10131039
| `->logger(LoggerInterface)` | fluent | none | PSR-3 logger |
1040+
| `->preferredChain(string)` | fluent | `''` | Preferred chain issuer CN/O (RFC 8555 §7.4.2); falls back to default chain if no match |
10141041
| `->skipLocalTest()` | fluent | off | Disable pre-flight HTTP/DNS self-check |
10151042
| `->skipCaaCheck()` | fluent | off | Disable CAA DNS pre-check (internal CAs, split-horizon DNS) |
10161043
| `->onIssued(callable)` | fluent | none | Callback fired after every successful issuance; receives `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
/** @var callable[] */
6162
private array $onIssuedCallbacks = [];
6263
/** @var callable[] */
@@ -184,6 +185,23 @@ public function skipCaaCheck(): self
184185
return $this;
185186
}
186187

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+
187205
/**
188206
* Register a callback invoked after every successful certificate issuance.
189207
* The callback receives the issued StoredCertificate as its sole argument.
@@ -367,7 +385,7 @@ private function fetchAndStoreCertificate(
367385
}
368386

369387
$order = $api->order()->waitUntilValid($order);
370-
$bundle = $api->certificate()->getBundle($order);
388+
$bundle = $api->certificate()->getBundle($order, $this->preferredChain ?: null);
371389

372390
$parsed = openssl_x509_parse($bundle->certificate);
373391
$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/CoyoteCertTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ public function cleanup(string $d, string $tok): void {}
8080
expect($c->skipCaaCheck())->toBe($c);
8181
});
8282

83+
it('preferredChain() returns self (fluent)', function () {
84+
$c = makeCoyote();
85+
expect($c->preferredChain('ISRG Root X1'))->toBe($c);
86+
});
87+
8388
it('profile() returns self (fluent)', function () {
8489
$c = makeCoyote();
8590
expect($c->profile('shortlived'))->toBe($c);

0 commit comments

Comments
 (0)