Skip to content

Commit 55c7f16

Browse files
committed
refactor(http-sig): drop Jwk wrapper, use Firebase\JWT\Key
Addresses review feedback from @CarlSchwan. Signed-off-by: Micke Nordin <kano@sunet.se>
1 parent c285b40 commit 55c7f16

20 files changed

Lines changed: 304 additions & 711 deletions

core/Command/OCM/ListKeys.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
3939
}
4040

4141
if ($keys === []) {
42-
$output->writeln('<comment>No Ed25519 keys yet one will be generated on first OCM request.</comment>');
42+
$output->writeln('<comment>No Ed25519 keys yet; one will be generated on first OCM request.</comment>');
4343
return 0;
4444
}
4545

lib/composer/composer/autoload_classmap.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2147,7 +2147,6 @@
21472147
'OC\\Security\\Ip\\Factory' => $baseDir . '/lib/private/Security/Ip/Factory.php',
21482148
'OC\\Security\\Ip\\Range' => $baseDir . '/lib/private/Security/Ip/Range.php',
21492149
'OC\\Security\\Ip\\RemoteAddress' => $baseDir . '/lib/private/Security/Ip/RemoteAddress.php',
2150-
'OC\\Security\\Jwks\\Jwk' => $baseDir . '/lib/private/Security/Jwks/Jwk.php',
21512150
'OC\\Security\\Normalizer\\IpAddress' => $baseDir . '/lib/private/Security/Normalizer/IpAddress.php',
21522151
'OC\\Security\\RateLimiting\\Backend\\DatabaseBackend' => $baseDir . '/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php',
21532152
'OC\\Security\\RateLimiting\\Backend\\IBackend' => $baseDir . '/lib/private/Security/RateLimiting/Backend/IBackend.php',

lib/composer/composer/autoload_static.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2188,7 +2188,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
21882188
'OC\\Security\\Ip\\Factory' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/Factory.php',
21892189
'OC\\Security\\Ip\\Range' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/Range.php',
21902190
'OC\\Security\\Ip\\RemoteAddress' => __DIR__ . '/../../..' . '/lib/private/Security/Ip/RemoteAddress.php',
2191-
'OC\\Security\\Jwks\\Jwk' => __DIR__ . '/../../..' . '/lib/private/Security/Jwks/Jwk.php',
21922191
'OC\\Security\\Normalizer\\IpAddress' => __DIR__ . '/../../..' . '/lib/private/Security/Normalizer/IpAddress.php',
21932192
'OC\\Security\\RateLimiting\\Backend\\DatabaseBackend' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Backend/DatabaseBackend.php',
21942193
'OC\\Security\\RateLimiting\\Backend\\IBackend' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Backend/IBackend.php',

lib/private/OCM/OCMJwksHandler.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public function handle(string $service, IRequestContext $context, ?IResponse $pr
4343
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
4444
try {
4545
foreach ($this->signatoryManager->getLocalEd25519Jwks() as $jwk) {
46-
$keys[] = $jwk->toArray();
46+
$keys[] = $jwk;
4747
}
4848
} catch (Throwable $e) {
4949
$this->logger->warning('failed to build local Ed25519 JWKs', ['exception' => $e]);

lib/private/OCM/OCMSignatoryManager.php

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@
99

1010
namespace OC\OCM;
1111

12+
use Firebase\JWT\JWK;
13+
use Firebase\JWT\JWT;
14+
use Firebase\JWT\Key;
1215
use JsonException;
1316
use OC\Security\IdentityProof\Manager;
14-
use OC\Security\Jwks\Jwk;
17+
use OC\Security\Signature\Rfc9421\Algorithm;
1518
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
1619
use OCP\Http\Client\IClientService;
1720
use OCP\IAppConfig;
@@ -51,7 +54,7 @@ class OCMSignatoryManager implements IJwkResolvingSignatoryManager {
5154
/**
5255
* Each Ed25519 keypair lives in a numbered "pool" appkey. Three slot
5356
* pointers (active/pending/retiring) reference pools by id, so rotation
54-
* is just pointer reshuffling no copying of keypair bytes.
57+
* is just pointer reshuffling, no copying of keypair bytes.
5558
*/
5659
private const APPKEY_ED25519_POOL_PREFIX = 'ocm_ed25519_pool_';
5760
private const APPCONFIG_ED25519_POOL_COUNTER = 'ocm_ed25519_pool_counter';
@@ -173,10 +176,10 @@ public function getLocalEd25519Signatory(): ?Signatory {
173176
* for their JWKS cache to expire.
174177
*
175178
* The active key is provisioned on demand if it does not yet exist, so
176-
* the first request to JWKS always returns at least one key — peers
179+
* the first request to JWKS always returns at least one key. Peers
177180
* must be able to discover us before we have ever signed anything.
178181
*
179-
* @return list<Jwk>
182+
* @return list<array<string, string>>
180183
*/
181184
public function getLocalEd25519Jwks(): array {
182185
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
@@ -191,7 +194,7 @@ public function getLocalEd25519Jwks(): array {
191194
}
192195
$signatory = $this->signatoryFromPool($poolId);
193196
if ($signatory !== null) {
194-
$jwks[] = Jwk::fromEd25519PublicKey($signatory->getPublicKey(), $signatory->getKeyId());
197+
$jwks[] = self::buildEd25519JwkArray($signatory->getPublicKey(), $signatory->getKeyId());
195198
}
196199
}
197200
return $jwks;
@@ -209,7 +212,7 @@ public function stageEd25519Key(): Signatory {
209212
if ($this->getSlotPool(self::SLOT_PENDING) !== null) {
210213
throw new \RuntimeException('a pending Ed25519 key already exists; activate or retire it first');
211214
}
212-
// Make sure we have an active key to begin with otherwise staging
215+
// Make sure we have an active key to begin with, otherwise staging
213216
// a "next" key with nothing to switch from would be odd.
214217
if ($this->getSlotPool(self::SLOT_ACTIVE) === null) {
215218
$this->getLocalEd25519Signatory();
@@ -303,7 +306,7 @@ public function listEd25519Keys(): array {
303306
* Generate a fresh Ed25519 keypair into a new pool, recording the kid
304307
* alongside. The kid is run through {@see Signatory::setKeyId} first so
305308
* the stored value matches the canonical form (no `/index.php/`, https
306-
* scheme) used on the wire otherwise admin output from
309+
* scheme) used on the wire, otherwise admin output from
307310
* `occ ocm:keys:list` would diverge from the kid actually published in
308311
* JWKS and used to sign requests.
309312
*
@@ -332,8 +335,8 @@ private function canonicalKid(string $kid): string {
332335
/**
333336
* Build a kid for a newly-generated key. The identity portion is derived
334337
* once (from the first request that creates a key) and persisted, so
335-
* later rotations including those triggered from CLI where there is
336-
* no Host header produce kids on the same hostname instead of falling
338+
* later rotations (including those triggered from CLI where there is
339+
* no Host header) produce kids on the same hostname instead of falling
337340
* back to `overwrite.cli.url`.
338341
*
339342
* @throws \RuntimeException if no instance identity can be derived
@@ -349,7 +352,7 @@ private function nextEd25519PoolKid(): string {
349352
* every Ed25519 kid this instance has ever published. Resolution order:
350353
*
351354
* 1. {@see APPCONFIG_ED25519_KID_BASE} if previously stored.
352-
* 2. The active pool's kid (with the `-N` suffix stripped) handles
355+
* 2. The active pool's kid (with the `-N` suffix stripped); handles
353356
* instances upgraded from a single-key world without an explicit
354357
* base appconfig.
355358
* 3. {@see buildLocalKeyId} as a fresh derivation from the request
@@ -492,13 +495,13 @@ public function getRemoteSignatory(string $remote): ?Signatory {
492495
* Results are cached per-origin for {@see JWKS_CACHE_TTL} seconds so the
493496
* common case of repeated inbound requests from the same peer doesn't
494497
* trigger a fresh HTTPS round-trip each time. On a cache hit where the
495-
* requested kid is missing, we refetch once — that lets a remote key
498+
* requested kid is missing, we refetch once. That lets a remote key
496499
* rotation propagate without waiting out the TTL.
497500
*
498-
* @return Jwk|null null when the fetch fails or no key with that kid is published
501+
* @return Key|null null when the fetch fails or no key with that kid is published
499502
*/
500503
#[\Override]
501-
public function getRemoteJwk(string $origin, string $keyId): ?Jwk {
504+
public function getRemoteKey(string $origin, string $keyId): ?Key {
502505
$keys = $this->readCachedJwks($origin);
503506
$fromCache = $keys !== null;
504507
if (!$fromCache) {
@@ -508,12 +511,12 @@ public function getRemoteJwk(string $origin, string $keyId): ?Jwk {
508511
}
509512
}
510513

511-
$jwk = $this->findKid($keys, $keyId);
512-
if ($jwk !== null) {
513-
return $jwk;
514+
$key = $this->findKid($keys, $keyId);
515+
if ($key !== null) {
516+
return $key;
514517
}
515518
// Only refetch if the answer came from the cache. A fresh fetch is
516-
// already authoritative refetching it just hammers the peer.
519+
// already authoritative; refetching it just hammers the peer.
517520
if (!$fromCache) {
518521
return null;
519522
}
@@ -584,15 +587,35 @@ private function fetchJwks(string $origin): ?array {
584587
/**
585588
* @param list<array<string, mixed>>|null $keys
586589
*/
587-
private function findKid(?array $keys, string $keyId): ?Jwk {
590+
private function findKid(?array $keys, string $keyId): ?Key {
588591
if ($keys === null) {
589592
return null;
590593
}
591594
foreach ($keys as $entry) {
592-
if (($entry['kid'] ?? null) === $keyId) {
593-
return Jwk::fromArray($entry);
595+
if (($entry['kid'] ?? null) !== $keyId) {
596+
continue;
597+
}
598+
try {
599+
return JWK::parseKey($entry, Algorithm::deriveJoseAlgFromJwk($entry));
600+
} catch (Throwable $e) {
601+
$this->logger->warning('failed to parse remote JWK', ['exception' => $e, 'kid' => $keyId]);
602+
return null;
594603
}
595604
}
596605
return null;
597606
}
607+
608+
/**
609+
* @return array<string, string>
610+
*/
611+
private static function buildEd25519JwkArray(string $rawPublicKey, string $kid): array {
612+
return [
613+
'kty' => 'OKP',
614+
'crv' => 'Ed25519',
615+
'kid' => $kid,
616+
'alg' => 'EdDSA',
617+
'use' => 'sig',
618+
'x' => JWT::urlsafeB64Encode($rawPublicKey),
619+
];
620+
}
598621
}

lib/private/OCM/Rfc9421SignatoryManager.php

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

1010
namespace OC\OCM;
1111

12-
use OC\Security\Jwks\Jwk;
12+
use Firebase\JWT\Key;
1313
use OC\Security\Signature\Rfc9421\IJwkResolvingSignatoryManager;
1414
use OCP\Security\Signature\Exceptions\IdentityNotFoundException;
1515
use OCP\Security\Signature\Model\Signatory;
@@ -21,7 +21,7 @@
2121
* advertised the OCM `http-sig` capability.
2222
*
2323
* Wrapping rather than mutating OCMSignatoryManager keeps the underlying
24-
* service stateless — important because it lives in the DI container and
24+
* service stateless. That matters because it lives in the DI container and
2525
* may be reused across requests.
2626
*/
2727
final class Rfc9421SignatoryManager implements IJwkResolvingSignatoryManager {
@@ -55,7 +55,7 @@ public function getRemoteSignatory(string $remote): ?Signatory {
5555
}
5656

5757
#[\Override]
58-
public function getRemoteJwk(string $origin, string $keyId): ?Jwk {
59-
return $this->delegate->getRemoteJwk($origin, $keyId);
58+
public function getRemoteKey(string $origin, string $keyId): ?Key {
59+
return $this->delegate->getRemoteKey($origin, $keyId);
6060
}
6161
}

lib/private/Security/IdentityProof/Manager.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ public function generateAppKey(string $app, string $name, array $options = []):
181181
/**
182182
* Generate an Ed25519 keypair via libsodium and persist it under the given
183183
* app/name. The Key returned holds the raw 32-byte public key and the raw
184-
* 64-byte secret key (libsodium format: seed||publickey) there is no
184+
* 64-byte secret key (libsodium format: seed||publickey); there is no
185185
* PEM wrapping. ext-sodium is a hard requirement of the server.
186186
*
187187
* Note: if a key already exists at this path it will be overwritten.

lib/private/Security/Jwks/Jwk.php

Lines changed: 0 additions & 73 deletions
This file was deleted.

lib/private/Security/Signature/Model/Rfc9421IncomingSignedRequest.php

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99

1010
namespace OC\Security\Signature\Model;
1111

12+
use Firebase\JWT\Key;
1213
use JsonSerializable;
13-
use OC\Security\Jwks\Jwk;
1414
use OC\Security\Signature\Rfc9421\Algorithm;
1515
use OC\Security\Signature\Rfc9421\ContentDigest;
1616
use OC\Security\Signature\Rfc9421\OcmProfile;
@@ -42,7 +42,7 @@
4242
* specification fixes that policy to the `ocm` label.
4343
*
4444
* The cryptographic check is deferred to {@see verify()} which requires that
45-
* a {@see Jwk} has been attached via {@see setJwk()}.
45+
* a {@see Key} has been attached via {@see setKey()}.
4646
*
4747
* Body integrity is enforced separately: if `content-digest` is present in the
4848
* covered components, the header value MUST hash to the body bytes per
@@ -55,7 +55,7 @@ class Rfc9421IncomingSignedRequest extends SignedRequest implements
5555
* Components a signature MUST cover to be considered authentic for OCM.
5656
* Missing any of these means the signer left a body or freshness window
5757
* unprotected. Callers can override via the `rfc9421.requiredComponents`
58-
* option but tightening below this baseline is on them.
58+
* option, but tightening below this baseline is on them.
5959
*/
6060
private const DEFAULT_REQUIRED_COMPONENTS = [
6161
'@method',
@@ -79,7 +79,7 @@ class Rfc9421IncomingSignedRequest extends SignedRequest implements
7979
private array $signatureParams;
8080
private string $signatureBaseString;
8181
private string $rawSignature;
82-
private ?Jwk $jwk = null;
82+
private ?Key $key = null;
8383

8484
/**
8585
* @throws IncomingRequestException if anything looks wrong with the request structure
@@ -185,16 +185,14 @@ public function getKeyId(): string {
185185
return $this->getSigningElement('keyId');
186186
}
187187

188-
/**
189-
* Attach the verification key. Required before {@see verify()} is called.
190-
*/
191-
public function setJwk(Jwk $jwk): self {
192-
$this->jwk = $jwk;
188+
/** Required before {@see verify()} is called. */
189+
public function setKey(Key $key): self {
190+
$this->key = $key;
193191
return $this;
194192
}
195193

196-
public function getJwk(): ?Jwk {
197-
return $this->jwk;
194+
public function getKey(): ?Key {
195+
return $this->key;
198196
}
199197

200198
/**
@@ -226,14 +224,14 @@ public function getSignatureBaseString(): string {
226224

227225
#[\Override]
228226
public function verify(): void {
229-
if ($this->jwk === null) {
227+
if ($this->key === null) {
230228
throw new SignatoryNotFoundException('no JWK set for verification');
231229
}
232230
try {
233231
$ok = Algorithm::verify(
234232
$this->signatureBaseString,
235233
$this->rawSignature,
236-
$this->jwk,
234+
$this->key,
237235
$this->getAlgorithm(),
238236
);
239237
} catch (SignatureException $e) {

0 commit comments

Comments
 (0)