Skip to content

Commit 2dbeea8

Browse files
committed
Phase 1: ECDSA JWS, EAB, order polling, ARI support
- JsonWebKey/JsonWebSignature/KeyId: add EC key support (ES256/ES384), fix DER→raw signature conversion for JOSE compliance - Account: add EAB outer JWS for ZeroSSL/GTS/SSL.com; fix detail typo (RFC 8555) - Order: add waitUntilValid() to poll processing→valid before cert download - CoyoteCert: wire email builder, order poll, and ARI renewal window check - Directory/RenewalInfo/RenewalWindow: RFC 9773 ARI endpoint with graceful fallback
1 parent 646f885 commit 2dbeea8

10 files changed

Lines changed: 306 additions & 39 deletions

File tree

src/Api.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Psr\Log\LoggerInterface;
66
use CoyoteCert\Endpoints\Account;
77
use CoyoteCert\Endpoints\Certificate;
8+
use CoyoteCert\Endpoints\RenewalInfo;
89
use CoyoteCert\Endpoints\Directory;
910
use CoyoteCert\Endpoints\DomainValidation;
1011
use CoyoteCert\Endpoints\Nonce;
@@ -75,6 +76,11 @@ public function certificate(): Certificate
7576
return new Certificate($this);
7677
}
7778

79+
public function renewalInfo(): RenewalInfo
80+
{
81+
return new RenewalInfo($this);
82+
}
83+
7884
public function getHttpClient(): HttpClientInterface
7985
{
8086
if ($this->httpClient === null) {

src/CoyoteCert.php

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Psr\Log\LoggerInterface;
77
use CoyoteCert\Enums\AuthorizationChallengeEnum;
88
use CoyoteCert\Enums\KeyType;
9+
use CoyoteCert\DTO\RenewalWindow;
910
use CoyoteCert\Exceptions\LetsEncryptClientException;
1011
use CoyoteCert\Interfaces\ChallengeHandlerInterface;
1112
use CoyoteCert\Provider\AcmeProviderInterface;
@@ -34,13 +35,14 @@
3435
*/
3536
class CoyoteCert
3637
{
37-
private ?StorageInterface $storage = null;
38-
private ?LoggerInterface $logger = null;
39-
private array $domains = [];
38+
private ?StorageInterface $storage = null;
39+
private ?LoggerInterface $logger = null;
40+
private string $email = '';
41+
private array $domains = [];
4042
private ?ChallengeHandlerInterface $challengeHandler = null;
41-
private KeyType $certKeyType = KeyType::EC_P256;
42-
private KeyType $accountKeyType = KeyType::RSA_2048;
43-
private bool $localTest = true;
43+
private KeyType $certKeyType = KeyType::EC_P256;
44+
private KeyType $accountKeyType = KeyType::RSA_2048;
45+
private bool $localTest = true;
4446

4547
private function __construct(private readonly AcmeProviderInterface $provider)
4648
{
@@ -53,6 +55,13 @@ public static function with(AcmeProviderInterface $provider): self
5355
return new self($provider);
5456
}
5557

58+
public function email(string $email): self
59+
{
60+
$this->email = $email;
61+
62+
return $this;
63+
}
64+
5665
public function storage(StorageInterface $storage): self
5766
{
5867
$this->storage = $storage;
@@ -126,7 +135,17 @@ public function needsRenewal(int $daysBeforeExpiry = 30): bool
126135

127136
$cert = $this->storage->getCertificate($this->domains[0]);
128137

129-
return $cert === null || $cert->remainingDays() <= $daysBeforeExpiry;
138+
if ($cert === null) {
139+
return true;
140+
}
141+
142+
$window = $this->ariWindow($cert);
143+
144+
if ($window !== null) {
145+
return $window->isOpen();
146+
}
147+
148+
return $cert->remainingDays() <= $daysBeforeExpiry;
130149
}
131150

132151
// ── Terminal actions ──────────────────────────────────────────────────────
@@ -148,7 +167,7 @@ public function issue(): StoredCertificate
148167
// ── 1. Get or create ACME account ──────────────────────────────────
149168
$account = $api->account()->exists()
150169
? $api->account()->get()
151-
: $api->account()->create();
170+
: $api->account()->create($this->email);
152171

153172
// ── 2. Create order ────────────────────────────────────────────────
154173
$order = $api->order()->new($account, $this->domains);
@@ -200,16 +219,19 @@ public function issue(): StoredCertificate
200219
throw new LetsEncryptClientException('Order finalization failed.');
201220
}
202221

203-
// ── 13. Download certificate bundle ───────────────────────────────
222+
// ── 13. Poll until order transitions processing → valid ────────────
223+
$order = $api->order()->waitUntilValid($order);
224+
225+
// ── 14. Download certificate bundle ───────────────────────────────
204226
$bundle = $api->certificate()->getBundle($order);
205227

206-
// ── 14. Parse expiry date from the DER-encoded certificate ─────────
228+
// ── 15. Parse expiry date from the DER-encoded certificate ─────────
207229
$parsed = openssl_x509_parse($bundle->certificate);
208230
$expiresAt = isset($parsed['validTo_time_t'])
209231
? (new DateTimeImmutable())->setTimestamp((int) $parsed['validTo_time_t'])
210232
: new DateTimeImmutable('+90 days');
211233

212-
// ── 15. Build and persist the stored certificate ───────────────────
234+
// ── 16. Build and persist the stored certificate ───────────────────
213235
$stored = new StoredCertificate(
214236
certificate: $bundle->certificate,
215237
privateKey: $certKeyPem,
@@ -252,6 +274,25 @@ public function issueOrRenew(int $daysBeforeExpiry = 30): StoredCertificate
252274

253275
// ── Private helpers ───────────────────────────────────────────────────────
254276

277+
private function ariWindow(StoredCertificate $cert): ?RenewalWindow
278+
{
279+
if (empty($cert->caBundle)) {
280+
return null;
281+
}
282+
283+
if (!preg_match('~(-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----)~', $cert->caBundle, $m)) {
284+
return null;
285+
}
286+
287+
try {
288+
return (new Api(provider: $this->provider, logger: $this->logger))
289+
->renewalInfo()
290+
->get($cert->certificate, $m[1]);
291+
} catch (\Throwable) {
292+
return null;
293+
}
294+
}
295+
255296
private function validate(): void
256297
{
257298
if (empty($this->domains)) {

src/DTO/RenewalWindow.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace CoyoteCert\DTO;
4+
5+
use DateTimeImmutable;
6+
7+
readonly class RenewalWindow
8+
{
9+
public function __construct(
10+
public DateTimeImmutable $start,
11+
public DateTimeImmutable $end,
12+
public ?string $explanationUrl = null,
13+
) {
14+
}
15+
16+
public function isOpen(): bool
17+
{
18+
$now = new DateTimeImmutable();
19+
20+
return $now >= $this->start && $now <= $this->end;
21+
}
22+
}

src/Endpoints/Account.php

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
namespace CoyoteCert\Endpoints;
44

55
use CoyoteCert\DTO\AccountData;
6+
use CoyoteCert\DTO\EabCredentials;
67
use CoyoteCert\Exceptions\LetsEncryptClientException;
78
use CoyoteCert\Http\Response;
9+
use CoyoteCert\Support\Base64;
10+
use CoyoteCert\Support\JsonWebKey;
811
use CoyoteCert\Support\JsonWebSignature;
912

1013
class Account extends Endpoint
@@ -14,13 +17,31 @@ public function exists(): bool
1417
return $this->client->localAccount()->exists();
1518
}
1619

17-
public function create(): AccountData
20+
public function create(string $email = ''): AccountData
1821
{
1922
$this->client->localAccount()->generateNewKeys();
2023

21-
$payload = [
22-
'termsOfServiceAgreed' => true,
23-
];
24+
$payload = ['termsOfServiceAgreed' => true];
25+
26+
if ($this->client->getProvider()->isEabRequired()) {
27+
$eab = $this->client->getProvider()->getEabCredentials($email);
28+
29+
if ($eab === null) {
30+
throw new LetsEncryptClientException(sprintf(
31+
'%s requires EAB credentials. Pass your EAB key ID and HMAC key when constructing the provider.',
32+
$this->client->getProvider()->getDisplayName()
33+
));
34+
}
35+
36+
$accountKey = $this->client->localAccount()->getPrivateKey();
37+
$newAccountUrl = $this->client->directory()->newAccount();
38+
39+
$payload['externalAccountBinding'] = $this->buildEab(
40+
json_encode(JsonWebKey::compute($accountKey), JSON_THROW_ON_ERROR),
41+
$newAccountUrl,
42+
$eab
43+
);
44+
}
2445

2546
$response = $this->postToAccountUrl($payload);
2647

@@ -49,6 +70,24 @@ public function get(): AccountData
4970
$this->throwError($response, 'Retrieving account failed');
5071
}
5172

73+
private function buildEab(string $jwkJson, string $url, EabCredentials $eab): array
74+
{
75+
$protected64 = Base64::urlSafeEncode(json_encode([
76+
'alg' => 'HS256',
77+
'kid' => $eab->kid,
78+
'url' => $url,
79+
], JSON_THROW_ON_ERROR));
80+
81+
$payload64 = Base64::urlSafeEncode($jwkJson);
82+
$hmacKey = Base64::urlSafeDecode($eab->hmacKey);
83+
84+
return [
85+
'protected' => $protected64,
86+
'payload' => $payload64,
87+
'signature' => Base64::urlSafeEncode(hash_hmac('sha256', $protected64.'.'.$payload64, $hmacKey, true)),
88+
];
89+
}
90+
5291
private function signPayload(array $payload): array
5392
{
5493
return JsonWebSignature::generate(
@@ -69,7 +108,7 @@ private function postToAccountUrl(array $payload): Response
69108

70109
protected function throwError(Response $response, string $defaultMessage): never
71110
{
72-
$message = $response->getBody()['details'] ?? $defaultMessage;
111+
$message = $response->getBody()['detail'] ?? $defaultMessage;
73112
$this->logResponse('error', $message, $response);
74113

75114
throw new LetsEncryptClientException($message);

src/Endpoints/Directory.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,9 @@ public function revoke(): string
4848
{
4949
return $this->all()->getBody()['revokeCert'];
5050
}
51+
52+
public function renewalInfo(): ?string
53+
{
54+
return $this->all()->getBody()['renewalInfo'] ?? null;
55+
}
5156
}

src/Endpoints/Order.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ public function get(string $id): OrderData
7878
};
7979
}
8080

81+
public function waitUntilValid(OrderData $order, int $maxAttempts = 10, int $sleepSeconds = 2): OrderData
82+
{
83+
for ($i = 0; $i < $maxAttempts; $i++) {
84+
$response = $this->client->getHttpClient()->post(
85+
$order->url,
86+
$this->createKeyId($order->accountUrl, $order->url)
87+
);
88+
89+
$body = $response->getBody();
90+
91+
if (($body['status'] ?? '') === 'valid') {
92+
return OrderData::fromResponse($response, $order->accountUrl);
93+
}
94+
95+
if (($body['status'] ?? '') === 'invalid') {
96+
throw new LetsEncryptClientException('Order became invalid during finalization.');
97+
}
98+
99+
sleep($sleepSeconds);
100+
}
101+
102+
throw new LetsEncryptClientException("Order did not become valid after {$maxAttempts} attempts.");
103+
}
104+
81105
public function finalize(OrderData $orderData, string $csr): bool
82106
{
83107
if (!$orderData->isReady()) {

src/Endpoints/RenewalInfo.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace CoyoteCert\Endpoints;
4+
5+
use DateTimeImmutable;
6+
use CoyoteCert\DTO\RenewalWindow;
7+
use CoyoteCert\Support\Base64;
8+
9+
class RenewalInfo extends Endpoint
10+
{
11+
public function get(string $certPem, string $issuerPem): ?RenewalWindow
12+
{
13+
$baseUrl = $this->client->directory()->renewalInfo();
14+
15+
if ($baseUrl === null) {
16+
return null;
17+
}
18+
19+
$url = rtrim($baseUrl, '/') . '/' . $this->certId($certPem, $issuerPem);
20+
$response = $this->client->getHttpClient()->get($url);
21+
22+
if ($response->getHttpResponseCode() !== 200) {
23+
return null;
24+
}
25+
26+
$body = $response->getBody();
27+
28+
return new RenewalWindow(
29+
start: new DateTimeImmutable($body['suggestedWindow']['start']),
30+
end: new DateTimeImmutable($body['suggestedWindow']['end']),
31+
explanationUrl: $body['explanationURL'] ?? null,
32+
);
33+
}
34+
35+
private function certId(string $certPem, string $issuerPem): string
36+
{
37+
$issuerSpki = $this->spkiDer(openssl_get_publickey($issuerPem));
38+
$issuerHash = Base64::urlSafeEncode(hash('sha256', $issuerSpki, true));
39+
40+
$parsed = openssl_x509_parse($certPem);
41+
$serial = Base64::urlSafeEncode(hex2bin($parsed['serialNumberHex']));
42+
43+
return $issuerHash . '.' . $serial;
44+
}
45+
46+
private function spkiDer(mixed $publicKey): string
47+
{
48+
// "BEGIN PUBLIC KEY" PEM wraps raw SPKI DER — strip the headers to get the DER bytes
49+
$pem = openssl_pkey_get_details($publicKey)['key'];
50+
51+
return base64_decode(implode('', array_map(
52+
'trim',
53+
array_filter(explode("\n", $pem), static fn ($l) => !str_starts_with($l, '-----'))
54+
)));
55+
}
56+
}

src/Support/JsonWebKey.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,25 @@ public static function compute(
1717

1818
$details = openssl_pkey_get_details($privateKey);
1919

20+
if ($details['type'] === OPENSSL_KEYTYPE_EC) {
21+
[$crv, $coordLen] = match ($details['ec']['curve_name']) {
22+
'prime256v1' => ['P-256', 32],
23+
'secp384r1' => ['P-384', 48],
24+
default => throw new LetsEncryptClientException("Unsupported EC curve: {$details['ec']['curve_name']}"),
25+
};
26+
27+
return [
28+
'crv' => $crv,
29+
'kty' => 'EC',
30+
'x' => Base64::urlSafeEncode(str_pad($details['ec']['x'], $coordLen, "\x00", STR_PAD_LEFT)),
31+
'y' => Base64::urlSafeEncode(str_pad($details['ec']['y'], $coordLen, "\x00", STR_PAD_LEFT)),
32+
];
33+
}
34+
2035
return [
21-
'e' => Base64::urlSafeEncode($details['rsa']['e']),
36+
'e' => Base64::urlSafeEncode($details['rsa']['e']),
2237
'kty' => 'RSA',
23-
'n' => Base64::urlSafeEncode($details['rsa']['n']),
38+
'n' => Base64::urlSafeEncode($details['rsa']['n']),
2439
];
2540
}
2641

0 commit comments

Comments
 (0)