Skip to content

Commit ec1c2fd

Browse files
committed
Add KeyPairResolver
1 parent 437b222 commit ec1c2fd

3 files changed

Lines changed: 336 additions & 0 deletions

File tree

src/Federation.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use SimpleSAML\OpenID\Jws\JwsVerifierDecorator;
4141
use SimpleSAML\OpenID\Serializers\JwsSerializerManagerDecorator;
4242
use SimpleSAML\OpenID\Utils\ArtifactFetcher;
43+
use SimpleSAML\OpenID\Utils\KeyPairResolver;
4344

4445
class Federation
4546
{
@@ -111,6 +112,8 @@ class Federation
111112

112113
protected ?TrustMarkStatusResponseFetcher $trustMarkStatusResponseFetcher = null;
113114

115+
protected ?KeyPairResolver $keyPairResolver = null;
116+
114117

115118
public function __construct(
116119
protected readonly SupportedAlgorithms $supportedAlgorithms = new SupportedAlgorithms(),
@@ -455,4 +458,13 @@ public function defaultTrustMarkStatusEndpointUsagePolicyEnum(): TrustMarkStatus
455458
{
456459
return $this->defaultTrustMarkStatusEndpointUsagePolicyEnum;
457460
}
461+
462+
463+
public function keyPairResolver(): KeyPairResolver
464+
{
465+
return $this->keyPairResolver ??= new KeyPairResolver(
466+
$this->helpers(),
467+
$this->logger,
468+
);
469+
}
458470
}

src/Utils/KeyPairResolver.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\OpenID\Utils;
6+
7+
use Psr\Log\LoggerInterface;
8+
use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum;
9+
use SimpleSAML\OpenID\Exceptions\OpenIdException;
10+
use SimpleSAML\OpenID\Helpers;
11+
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair;
12+
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag;
13+
14+
class KeyPairResolver
15+
{
16+
public function __construct(
17+
protected readonly Helpers $helpers,
18+
protected readonly ?LoggerInterface $logger = null,
19+
) {
20+
}
21+
22+
23+
/**
24+
* @param mixed[] $receiverEntityMetadata
25+
* @param mixed[] $senderEntityMetadata
26+
* @throws \SimpleSAML\OpenID\Exceptions\InvalidValueException
27+
* @throws \SimpleSAML\OpenID\Exceptions\OpenIdException
28+
*/
29+
public function resolveSignatureKeyPairByAlgorithm(
30+
SignatureKeyPairBag $signatureKeyPairBag,
31+
array $receiverEntityMetadata = [],
32+
array $senderEntityMetadata = [],
33+
?string $receiverDesignatedSignatureAlgorithmMetadataKey = null,
34+
?string $receiverSupportedSignatureAlgorithmsMetadataKey = null,
35+
?string $senderDesignatedSignatureAlgorithmMetadataKey = null,
36+
?string $senderSupportedSignatureAlgorithmsMetadataKey = null,
37+
): SignatureKeyPair {
38+
$signatureKeyPair = $signatureKeyPairBag->getFirstOrFail();
39+
$this->logger?->debug(
40+
'Default Signature Key Pair: ',
41+
[
42+
'algorithm' => $signatureKeyPair->getSignatureAlgorithm()->value,
43+
'keyId' => $signatureKeyPair->getKeyPair()->getKeyId(),
44+
],
45+
);
46+
47+
$targetAlgorithms = [];
48+
49+
// Designated algorithms take precedence.
50+
if (
51+
is_string($receiverDesignatedSignatureAlgorithmMetadataKey) &&
52+
array_key_exists($receiverDesignatedSignatureAlgorithmMetadataKey, $receiverEntityMetadata) &&
53+
is_string($receiverDesignatedAlg = $receiverEntityMetadata[
54+
$receiverDesignatedSignatureAlgorithmMetadataKey
55+
])
56+
) {
57+
$this->logger?->debug('Receiver designated signature algorithm: ' . $receiverDesignatedAlg);
58+
$targetAlgorithms['receiver'] = $receiverDesignatedAlg;
59+
}
60+
61+
if (
62+
is_string($senderDesignatedSignatureAlgorithmMetadataKey) &&
63+
array_key_exists($senderDesignatedSignatureAlgorithmMetadataKey, $senderEntityMetadata) &&
64+
is_string($senderDesignatedAlg = $senderEntityMetadata[$senderDesignatedSignatureAlgorithmMetadataKey])
65+
) {
66+
$this->logger?->debug('Sender designated signature algorithm: ' . $senderDesignatedAlg);
67+
$targetAlgorithms['sender'] = $senderDesignatedAlg;
68+
}
69+
70+
// If both sides have designated algorithms, they MUST match.
71+
if (count($targetAlgorithms) === 2 && $targetAlgorithms['receiver'] !== $targetAlgorithms['sender']) {
72+
$this->logger?->error(
73+
'Conflict in designated signature algorithms between receiver and sender.',
74+
$targetAlgorithms,
75+
);
76+
throw new OpenIdException('Conflict in designated signature algorithms between receiver and sender.');
77+
}
78+
79+
if ($targetAlgorithms !== []) {
80+
$algorithm = reset($targetAlgorithms);
81+
return $signatureKeyPairBag->getFirstByAlgorithmOrFail(
82+
SignatureAlgorithmEnum::from($algorithm),
83+
);
84+
}
85+
86+
// No designated algorithm, check supported ones.
87+
$commonlySupportedAlgorithms = $signatureKeyPairBag->getAllAlgorithmNamesUnique();
88+
$this->logger?->debug('Local supported signature algorithms: ' . implode(', ', $commonlySupportedAlgorithms));
89+
90+
if (
91+
is_string($receiverSupportedSignatureAlgorithmsMetadataKey) &&
92+
array_key_exists($receiverSupportedSignatureAlgorithmsMetadataKey, $receiverEntityMetadata) &&
93+
is_array($receiverSupportedAlgs = $receiverEntityMetadata[$receiverSupportedSignatureAlgorithmsMetadataKey])
94+
) {
95+
$receiverSupportedAlgs = $this->helpers->type()
96+
->enforceNonEmptyArrayWithValuesAsNonEmptyStrings($receiverSupportedAlgs);
97+
$this->logger?->debug('Receiver supported signature algorithms: ' . implode(', ', $receiverSupportedAlgs));
98+
99+
$commonlySupportedAlgorithms = array_intersect($commonlySupportedAlgorithms, $receiverSupportedAlgs);
100+
}
101+
102+
if (
103+
is_string($senderSupportedSignatureAlgorithmsMetadataKey) &&
104+
array_key_exists($senderSupportedSignatureAlgorithmsMetadataKey, $senderEntityMetadata) &&
105+
is_array($senderSupportedAlgs = $senderEntityMetadata[$senderSupportedSignatureAlgorithmsMetadataKey])
106+
) {
107+
$senderSupportedAlgs = $this->helpers->type()
108+
->ensureArrayWithValuesAsNonEmptyStrings($senderSupportedAlgs);
109+
$this->logger?->debug('Sender supported signature algorithms: ' . implode(', ', $senderSupportedAlgs));
110+
111+
$commonlySupportedAlgorithms = array_intersect($commonlySupportedAlgorithms, $senderSupportedAlgs);
112+
}
113+
114+
if ($commonlySupportedAlgorithms !== []) {
115+
$commonlySupportedAlgorithms = $this->helpers->type()
116+
->enforceNonEmptyArrayWithValuesAsNonEmptyStrings($commonlySupportedAlgorithms);
117+
118+
$this->logger?->debug(
119+
'Commonly supported signature algorithms found: ' . implode(', ', $commonlySupportedAlgorithms),
120+
);
121+
122+
return $signatureKeyPairBag->getFirstByAlgorithmOrFail(
123+
SignatureAlgorithmEnum::from(reset($commonlySupportedAlgorithms)),
124+
);
125+
}
126+
127+
$this->logger?->debug('No commonly supported signature algorithms found. Using default.');
128+
129+
$this->logger?->debug(
130+
'Signature Key Pair after algorithm selection: ',
131+
[
132+
'algorithm' => $signatureKeyPair->getSignatureAlgorithm()->value,
133+
'keyId' => $signatureKeyPair->getKeyPair()->getKeyId(),
134+
],
135+
);
136+
137+
return $signatureKeyPair;
138+
}
139+
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\OpenID\Utils;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\Attributes\UsesClass;
9+
use PHPUnit\Framework\MockObject\MockObject;
10+
use PHPUnit\Framework\TestCase;
11+
use Psr\Log\LoggerInterface;
12+
use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum;
13+
use SimpleSAML\OpenID\Exceptions\OpenIdException;
14+
use SimpleSAML\OpenID\Helpers;
15+
use SimpleSAML\OpenID\Helpers\Type;
16+
use SimpleSAML\OpenID\Utils\KeyPairResolver;
17+
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair;
18+
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag;
19+
20+
#[CoversClass(KeyPairResolver::class)]
21+
#[UsesClass(Type::class)]
22+
final class KeyPairResolverTest extends TestCase
23+
{
24+
private MockObject $helpersMock;
25+
26+
private MockObject $loggerMock;
27+
28+
private MockObject $signatureKeyPairBagMock;
29+
30+
private MockObject $defaultKeyPairMock;
31+
32+
33+
protected function setUp(): void
34+
{
35+
$this->helpersMock = $this->createMock(\SimpleSAML\OpenID\Helpers::class);
36+
$typeHelper = new Type();
37+
$this->helpersMock->method('type')->willReturn($typeHelper);
38+
$this->loggerMock = $this->createMock(LoggerInterface::class);
39+
40+
$this->signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class);
41+
42+
$this->defaultKeyPairMock = $this->createMock(SignatureKeyPair::class);
43+
$this->defaultKeyPairMock->method('getSignatureAlgorithm')
44+
->willReturn(SignatureAlgorithmEnum::RS256);
45+
46+
$this->signatureKeyPairBagMock->method('getFirstOrFail')
47+
->willReturn($this->defaultKeyPairMock);
48+
}
49+
50+
51+
protected function sut(
52+
?Helpers $helpers = null,
53+
?LoggerInterface $logger = null,
54+
): KeyPairResolver {
55+
$helpers ??= $this->helpersMock;
56+
$logger ??= $this->loggerMock;
57+
58+
return new KeyPairResolver($helpers, $logger);
59+
}
60+
61+
62+
public function testResolveWithReceiverDesignatedAlgorithm(): void
63+
{
64+
$receiverMetadata = [
65+
'receiver_alg' => 'RS256',
66+
];
67+
68+
$keyPairMock = $this->createMock(SignatureKeyPair::class);
69+
$keyPairMock->method('getSignatureAlgorithm')
70+
->willReturn(SignatureAlgorithmEnum::RS256);
71+
$this->signatureKeyPairBagMock->expects($this->once())
72+
->method('getFirstByAlgorithmOrFail')
73+
->with(SignatureAlgorithmEnum::RS256)
74+
->willReturn($keyPairMock);
75+
76+
$result = $this->sut()->resolveSignatureKeyPairByAlgorithm(
77+
$this->signatureKeyPairBagMock,
78+
$receiverMetadata,
79+
[],
80+
'receiver_alg',
81+
);
82+
83+
$this->assertSame($keyPairMock, $result);
84+
}
85+
86+
87+
public function testResolveWithSenderDesignatedAlgorithm(): void
88+
{
89+
$senderMetadata = [
90+
'sender_alg' => 'ES256',
91+
];
92+
93+
$keyPairMock = $this->createMock(SignatureKeyPair::class);
94+
$keyPairMock->method('getSignatureAlgorithm')
95+
->willReturn(SignatureAlgorithmEnum::ES256);
96+
97+
$this->signatureKeyPairBagMock->expects($this->once())
98+
->method('getFirstByAlgorithmOrFail')
99+
->with(SignatureAlgorithmEnum::ES256)
100+
->willReturn($keyPairMock);
101+
102+
$result = $this->sut()->resolveSignatureKeyPairByAlgorithm(
103+
$this->signatureKeyPairBagMock,
104+
[],
105+
$senderMetadata,
106+
null,
107+
null,
108+
'sender_alg',
109+
);
110+
111+
$this->assertSame($keyPairMock, $result);
112+
}
113+
114+
115+
public function testResolveConflictDesignatedAlgorithmsThrows(): void
116+
{
117+
$receiverMetadata = ['alg' => 'RS256'];
118+
$senderMetadata = ['alg' => 'ES256'];
119+
120+
$this->expectException(OpenIdException::class);
121+
$this->expectExceptionMessage('Conflict in designated signature algorithms');
122+
123+
$this->sut()->resolveSignatureKeyPairByAlgorithm(
124+
$this->signatureKeyPairBagMock,
125+
$receiverMetadata,
126+
$senderMetadata,
127+
'alg',
128+
null,
129+
'alg',
130+
);
131+
}
132+
133+
134+
public function testResolveCommonlySupportedAlgorithms(): void
135+
{
136+
$receiverMetadata = ['supported' => ['RS256', 'ES256']];
137+
$senderMetadata = ['supported' => ['ES256', 'PS256']];
138+
139+
$this->signatureKeyPairBagMock->method('getAllAlgorithmNamesUnique')
140+
->willReturn(['RS256', 'ES256', 'PS256']);
141+
142+
$keyPairMock = $this->createMock(SignatureKeyPair::class);
143+
$keyPairMock->method('getSignatureAlgorithm')
144+
->willReturn(SignatureAlgorithmEnum::ES256);
145+
$this->signatureKeyPairBagMock->expects($this->once())
146+
->method('getFirstByAlgorithmOrFail')
147+
->with(SignatureAlgorithmEnum::ES256)
148+
->willReturn($keyPairMock);
149+
150+
$result = $this->sut()->resolveSignatureKeyPairByAlgorithm(
151+
$this->signatureKeyPairBagMock,
152+
$receiverMetadata,
153+
$senderMetadata,
154+
null,
155+
'supported',
156+
null,
157+
'supported',
158+
);
159+
160+
$this->assertSame($keyPairMock, $result);
161+
}
162+
163+
164+
public function testResolveFallbackToDefaultWhenNoMatch(): void
165+
{
166+
$receiverMetadata = ['supported' => ['RS256']];
167+
$senderMetadata = ['supported' => ['ES256']];
168+
169+
$this->signatureKeyPairBagMock->method('getAllAlgorithmNamesUnique')
170+
->willReturn(['PS256']);
171+
172+
173+
$result = $this->sut()->resolveSignatureKeyPairByAlgorithm(
174+
$this->signatureKeyPairBagMock,
175+
$receiverMetadata,
176+
$senderMetadata,
177+
null,
178+
'supported',
179+
null,
180+
'supported',
181+
);
182+
183+
$this->assertSame($this->defaultKeyPairMock, $result);
184+
}
185+
}

0 commit comments

Comments
 (0)