Skip to content

Commit 8f2999a

Browse files
authored
Merge pull request #6 from blendbyte/dual-algo-certs
Add dual-algorithm certificate storage (RSA + ECDSA per domain)
2 parents e9f2e6f + 8c29114 commit 8f2999a

12 files changed

Lines changed: 207 additions & 77 deletions

README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ Files written:
368368
|---|---|
369369
| `/var/certs/account.pem` | ACME account private key (mode 0600) |
370370
| `/var/certs/account.json` | Key type metadata |
371-
| `/var/certs/{domain}.cert.json` | Serialised `StoredCertificate` |
371+
| `/var/certs/{domain}.{KeyType}.cert.json` | Serialised `StoredCertificate` (e.g. `example.com.EC_P256.cert.json`) |
372372

373373
The directory is created automatically (mode 0700). Reads use shared locks, writes use exclusive locks, safe for concurrent processes.
374374

@@ -453,25 +453,25 @@ class RedisStorage implements StorageInterface
453453
$this->redis->set('acme:account:type', $type->value);
454454
}
455455

456-
public function hasCertificate(string $domain): bool
456+
public function hasCertificate(string $domain, KeyType $keyType): bool
457457
{
458-
return (bool) $this->redis->exists("acme:cert:{$domain}");
458+
return (bool) $this->redis->exists("acme:cert:{$domain}:{$keyType->value}");
459459
}
460460

461-
public function getCertificate(string $domain): ?StoredCertificate
461+
public function getCertificate(string $domain, KeyType $keyType): ?StoredCertificate
462462
{
463-
$json = $this->redis->get("acme:cert:{$domain}");
463+
$json = $this->redis->get("acme:cert:{$domain}:{$keyType->value}");
464464
return $json ? StoredCertificate::fromArray(json_decode($json, true)) : null;
465465
}
466466

467467
public function saveCertificate(string $domain, StoredCertificate $cert): void
468468
{
469-
$this->redis->set("acme:cert:{$domain}", json_encode($cert->toArray()));
469+
$this->redis->set("acme:cert:{$domain}:{$cert->keyType->value}", json_encode($cert->toArray()));
470470
}
471471

472-
public function deleteCertificate(string $domain): void
472+
public function deleteCertificate(string $domain, KeyType $keyType): void
473473
{
474-
$this->redis->del("acme:cert:{$domain}");
474+
$this->redis->del("acme:cert:{$domain}:{$keyType->value}");
475475
}
476476
}
477477
```
@@ -766,14 +766,15 @@ Revoke a stored certificate with an optional RFC 5280 reason code.
766766

767767
```php
768768
use CoyoteCert\CoyoteCert;
769+
use CoyoteCert\Enums\KeyType;
769770
use CoyoteCert\Enums\RevocationReason;
770771
use CoyoteCert\Provider\LetsEncrypt;
771772
use CoyoteCert\Storage\FilesystemStorage;
772773

773774
$storage = new FilesystemStorage('/var/certs');
774775
$coyote = CoyoteCert::with(new LetsEncrypt())->storage($storage);
775776

776-
$cert = $storage->getCertificate('example.com');
777+
$cert = $storage->getCertificate('example.com', KeyType::EC_P256);
777778

778779
$coyote->revoke($cert); // Unspecified (default)
779780
$coyote->revoke($cert, RevocationReason::KeyCompromise);
@@ -791,7 +792,7 @@ Returns `true` on success, `false` if the CA rejected the request.
791792
After revoking, remove the stored certificate so `issueOrRenew()` will request a fresh one:
792793

793794
```php
794-
$storage->deleteCertificate('example.com');
795+
$storage->deleteCertificate('example.com', KeyType::EC_P256);
795796
```
796797

797798
---

src/CoyoteCert.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ public function needsRenewal(int $daysBeforeExpiry = 30): bool
238238
return true;
239239
}
240240

241-
$cert = $this->storage->getCertificate($this->domains[0]);
241+
$cert = $this->storage->getCertificate($this->domains[0], $this->certKeyType);
242242

243243
if ($cert === null) {
244244
return true;
@@ -290,7 +290,7 @@ public function issue(): StoredCertificate
290290
$account = $this->getOrCreateAccount($api);
291291

292292
$replacesId = '';
293-
$existingCert = $this->storage?->getCertificate($this->domains[0]);
293+
$existingCert = $this->storage?->getCertificate($this->domains[0], $this->certKeyType);
294294
if ($existingCert !== null && ($issuerPem = $this->extractIssuerPem($existingCert)) !== null) {
295295
try {
296296
$replacesId = $api->renewalInfo()->certId($existingCert->certificate, $issuerPem);
@@ -382,6 +382,7 @@ private function fetchAndStoreCertificate(
382382
issuedAt: new DateTimeImmutable(),
383383
expiresAt: $expiresAt,
384384
domains: $this->domains,
385+
keyType: $this->certKeyType,
385386
);
386387

387388
if ($this->storage !== null) {
@@ -448,7 +449,7 @@ public function issueOrRenew(int $daysBeforeExpiry = 30): StoredCertificate
448449
throw new AcmeException('Certificate unexpectedly missing from storage.');
449450
}
450451

451-
return $this->storage->getCertificate($this->domains[0])
452+
return $this->storage->getCertificate($this->domains[0], $this->certKeyType)
452453
?? throw new AcmeException('Certificate unexpectedly missing from storage.');
453454
}
454455

src/Storage/DatabaseStorage.php

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,14 @@ public function saveAccountKey(string $pem, KeyType $type): void
9999

100100
// ── Certificates ─────────────────────────────────────────────────────────
101101

102-
public function hasCertificate(string $domain): bool
102+
public function hasCertificate(string $domain, KeyType $keyType): bool
103103
{
104-
return $this->get($this->certKey($domain)) !== null;
104+
return $this->get($this->certKey($domain, $keyType)) !== null;
105105
}
106106

107-
public function getCertificate(string $domain): ?StoredCertificate
107+
public function getCertificate(string $domain, KeyType $keyType): ?StoredCertificate
108108
{
109-
$json = $this->get($this->certKey($domain));
109+
$json = $this->get($this->certKey($domain, $keyType));
110110

111111
if ($json === null) {
112112
return null;
@@ -120,24 +120,24 @@ public function getCertificate(string $domain): ?StoredCertificate
120120
public function saveCertificate(string $domain, StoredCertificate $cert): void
121121
{
122122
$this->set(
123-
$this->certKey($domain),
123+
$this->certKey($domain, $cert->keyType),
124124
json_encode($cert->toArray(), JSON_THROW_ON_ERROR),
125125
);
126126
}
127127

128-
public function deleteCertificate(string $domain): void
128+
public function deleteCertificate(string $domain, KeyType $keyType): void
129129
{
130130
$stmt = $this->pdo->prepare(
131131
"DELETE FROM `{$this->table}` WHERE `store_key` = :key",
132132
);
133-
$stmt->execute([':key' => $this->certKey($domain)]);
133+
$stmt->execute([':key' => $this->certKey($domain, $keyType)]);
134134
}
135135

136136
// ── PDO helpers ───────────────────────────────────────────────────────────
137137

138-
private function certKey(string $domain): string
138+
private function certKey(string $domain, KeyType $keyType): string
139139
{
140-
return 'cert:' . $domain;
140+
return 'cert:' . $domain . ':' . $keyType->value;
141141
}
142142

143143
private function get(string $key): ?string

src/Storage/FilesystemStorage.php

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,44 @@ public function saveAccountKey(string $pem, KeyType $type): void
4444

4545
// ── Certificates ─────────────────────────────────────────────────────────
4646

47-
public function hasCertificate(string $domain): bool
47+
public function hasCertificate(string $domain, KeyType $keyType): bool
4848
{
49-
return file_exists($this->certPath($domain));
49+
if (file_exists($this->certPath($domain, $keyType))) {
50+
return true;
51+
}
52+
53+
// Legacy path without key-type suffix — treated as present for migration.
54+
return file_exists($this->legacyCertPath($domain));
5055
}
5156

52-
public function getCertificate(string $domain): ?StoredCertificate
57+
public function getCertificate(string $domain, KeyType $keyType): ?StoredCertificate
5358
{
54-
if (!$this->hasCertificate($domain)) {
55-
return null;
59+
$path = $this->certPath($domain, $keyType);
60+
61+
if (!file_exists($path)) {
62+
// Attempt transparent migration of a legacy single-cert file.
63+
$legacy = $this->legacyCertPath($domain);
64+
65+
if (!file_exists($legacy)) {
66+
return null;
67+
}
68+
69+
$data = json_decode($this->readFile($legacy), true, 512, JSON_THROW_ON_ERROR);
70+
$cert = StoredCertificate::fromArray($data);
71+
72+
// Only migrate when the legacy cert's key type matches what's requested.
73+
if ($cert->keyType !== $keyType) {
74+
return null;
75+
}
76+
77+
$this->ensureDirectory();
78+
$this->writeFile($path, json_encode($cert->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT));
79+
unlink($legacy);
80+
81+
return $cert;
5682
}
5783

58-
$data = json_decode($this->readFile($this->certPath($domain)), true, 512, JSON_THROW_ON_ERROR);
84+
$data = json_decode($this->readFile($path), true, 512, JSON_THROW_ON_ERROR);
5985

6086
return StoredCertificate::fromArray($data);
6187
}
@@ -64,17 +90,26 @@ public function saveCertificate(string $domain, StoredCertificate $cert): void
6490
{
6591
$this->ensureDirectory();
6692
$this->writeFile(
67-
$this->certPath($domain),
93+
$this->certPath($domain, $cert->keyType),
6894
json_encode($cert->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT),
6995
);
7096
}
7197

72-
public function deleteCertificate(string $domain): void
98+
public function deleteCertificate(string $domain, KeyType $keyType): void
7399
{
74-
$path = $this->certPath($domain);
100+
$path = $this->certPath($domain, $keyType);
75101

76102
if (file_exists($path)) {
77103
unlink($path);
104+
105+
return;
106+
}
107+
108+
// Also remove a legacy file if it exists for this domain.
109+
$legacy = $this->legacyCertPath($domain);
110+
111+
if (file_exists($legacy)) {
112+
unlink($legacy);
78113
}
79114
}
80115

@@ -90,9 +125,16 @@ private function accountMetaPath(): string
90125
return $this->dir() . 'account.json';
91126
}
92127

93-
private function certPath(string $domain): string
128+
private function certPath(string $domain, KeyType $keyType): string
129+
{
130+
$safe = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $domain);
131+
132+
return $this->dir() . $safe . '.' . $keyType->value . '.cert.json';
133+
}
134+
135+
/** Pre-v2 path — one cert per domain, no key-type suffix. */
136+
private function legacyCertPath(string $domain): string
94137
{
95-
// Sanitise the domain so it is safe to use as a filename.
96138
$safe = preg_replace('/[^a-zA-Z0-9._\-]/', '_', $domain);
97139

98140
return $this->dir() . $safe . '.cert.json';

src/Storage/InMemoryStorage.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class InMemoryStorage implements StorageInterface
1414
private ?string $accountKey = null;
1515
private ?KeyType $accountKeyType = null;
1616

17-
/** @var array<string, StoredCertificate> */
17+
/** @var array<string, StoredCertificate> Keyed as "{domain}:{KeyType->value}". */
1818
private array $certificates = [];
1919

2020
// ── Account key ──────────────────────────────────────────────────────────
@@ -50,23 +50,30 @@ public function saveAccountKey(string $pem, KeyType $type): void
5050

5151
// ── Certificates ─────────────────────────────────────────────────────────
5252

53-
public function hasCertificate(string $domain): bool
53+
public function hasCertificate(string $domain, KeyType $keyType): bool
5454
{
55-
return isset($this->certificates[$domain]);
55+
return isset($this->certificates[$this->certKey($domain, $keyType)]);
5656
}
5757

58-
public function getCertificate(string $domain): ?StoredCertificate
58+
public function getCertificate(string $domain, KeyType $keyType): ?StoredCertificate
5959
{
60-
return $this->certificates[$domain] ?? null;
60+
return $this->certificates[$this->certKey($domain, $keyType)] ?? null;
6161
}
6262

6363
public function saveCertificate(string $domain, StoredCertificate $cert): void
6464
{
65-
$this->certificates[$domain] = $cert;
65+
$this->certificates[$this->certKey($domain, $cert->keyType)] = $cert;
6666
}
6767

68-
public function deleteCertificate(string $domain): void
68+
public function deleteCertificate(string $domain, KeyType $keyType): void
6969
{
70-
unset($this->certificates[$domain]);
70+
unset($this->certificates[$this->certKey($domain, $keyType)]);
71+
}
72+
73+
// ── Helpers ───────────────────────────────────────────────────────────────
74+
75+
private function certKey(string $domain, KeyType $keyType): string
76+
{
77+
return $domain . ':' . $keyType->value;
7178
}
7279
}

src/Storage/StorageInterface.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,20 @@ public function saveAccountKey(string $pem, KeyType $type): void;
2222

2323
/**
2424
* @param string $domain Primary domain (used as the storage key).
25+
* @param KeyType $keyType The key algorithm for the certificate to look up.
2526
*/
26-
public function hasCertificate(string $domain): bool;
27+
public function hasCertificate(string $domain, KeyType $keyType): bool;
2728

28-
public function getCertificate(string $domain): ?StoredCertificate;
29+
public function getCertificate(string $domain, KeyType $keyType): ?StoredCertificate;
2930

31+
/**
32+
* Persist a certificate. The storage key is derived from the primary domain
33+
* plus the key type carried inside $cert.
34+
*/
3035
public function saveCertificate(string $domain, StoredCertificate $cert): void;
3136

3237
/**
33-
* Remove a stored certificate. No-op when the domain is not found.
38+
* Remove a stored certificate. No-op when the domain/key-type is not found.
3439
*/
35-
public function deleteCertificate(string $domain): void;
40+
public function deleteCertificate(string $domain, KeyType $keyType): void;
3641
}

src/Storage/StoredCertificate.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace CoyoteCert\Storage;
44

5+
use CoyoteCert\Enums\KeyType;
6+
57
readonly class StoredCertificate
68
{
79
/**
@@ -15,6 +17,7 @@ public function __construct(
1517
public \DateTimeImmutable $issuedAt,
1618
public \DateTimeImmutable $expiresAt,
1719
public array $domains,
20+
public KeyType $keyType = KeyType::EC_P256,
1821
) {}
1922

2023
/** @return array<string, mixed> */
@@ -28,6 +31,7 @@ public function toArray(): array
2831
'issued_at' => $this->issuedAt->format(\DateTimeInterface::ATOM),
2932
'expires_at' => $this->expiresAt->format(\DateTimeInterface::ATOM),
3033
'domains' => $this->domains,
34+
'key_type' => $this->keyType->value,
3135
];
3236
}
3337

@@ -42,6 +46,7 @@ public static function fromArray(array $data): self
4246
issuedAt: new \DateTimeImmutable($data['issued_at']),
4347
expiresAt: new \DateTimeImmutable($data['expires_at']),
4448
domains: $data['domains'],
49+
keyType: isset($data['key_type']) ? KeyType::from($data['key_type']) : KeyType::EC_P256,
4550
);
4651
}
4752

tests/Unit/CoyoteCertTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function makeCoyoteCert(string $caBundle = '', int $expiresInDays = 90): StoredC
2727
issuedAt: new DateTimeImmutable(),
2828
expiresAt: new DateTimeImmutable("+{$expiresInDays} days"),
2929
domains: ['example.com'],
30+
keyType: KeyType::EC_P256,
3031
);
3132
}
3233

@@ -323,6 +324,7 @@ public function sendRequest(\Psr\Http\Message\RequestInterface $r): \Psr\Http\Me
323324
issuedAt: new DateTimeImmutable(),
324325
expiresAt: new DateTimeImmutable('+90 days'),
325326
domains: ['example.com'],
327+
keyType: KeyType::EC_P256,
326328
));
327329

328330
$factory = new \Nyholm\Psr7\Factory\Psr17Factory();

tests/Unit/DTO/StoredCertificateTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ function makeStoredCertWithAki(): array
104104
it('toArray contains all expected keys', function () {
105105
$data = makeCert()->toArray();
106106

107-
expect($data)->toHaveKeys(['certificate', 'private_key', 'fullchain', 'ca_bundle', 'issued_at', 'expires_at', 'domains']);
107+
expect($data)->toHaveKeys(['certificate', 'private_key', 'fullchain', 'ca_bundle', 'issued_at', 'expires_at', 'domains', 'key_type']);
108108
});
109109

110110
it('remainingDays returns approximate days until expiry', function () {

0 commit comments

Comments
 (0)