Skip to content

Commit a7aa3be

Browse files
committed
Assert Status List controls stay out of published metadata, and document key and origin hazards
1 parent 2504cac commit a7aa3be

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

docs/3-oidc-configuration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,25 @@ Each list records the profile it was created under. Changing the setting therefo
514514
credentials to newly created lists, while existing lists keep being served under the profile their
515515
holders already resolved them by — so changing it never invalidates anything already in a wallet.
516516

517+
**A signing key has to outlive every list signed with it.** Each list records the key it was created
518+
with and is re-signed from that key alone, never from whichever key is current. Rotating keys is
519+
therefore safe in itself: new lists take the new key, existing lists keep theirs. Removing the old key
520+
from the configuration is what breaks things — and it breaks them on a delay.
521+
522+
A published token is served from storage without the key being consulted at all, so a list whose token
523+
is still fresh goes on answering `200` after its key is gone. The failure arrives only when that list
524+
next needs re-signing: when its contents change, when its refresh interval comes round, or as its token
525+
nears expiry. The module will not sign a list with a key its holders never bound to, so it answers `503`
526+
for that list instead. **Checking the endpoint just after removing a key therefore proves nothing**
527+
and since a revocation is the most likely thing to force a re-sign, the breakage tends to appear exactly
528+
when the list matters most.
529+
530+
Whether credentials stay verifiable in the meantime depends on the profile. A `did_jwk` token carries
531+
its own key, so tokens already published keep verifying. Under `jwks` the key is resolved through this
532+
module's published JWKS, which the same removal empties, so already published tokens stop verifying too
533+
once Relying Parties refetch it. A key is only safe to discard once every list it signed has been
534+
retired, which the lifecycle below does only after the last credential in those lists has expired.
535+
517536
### Credential expiry
518537

519538
Credentials this module issues do not expire unless you say so:
@@ -535,6 +554,13 @@ administration screen reports how many lists are in that position.
535554
Lists are published at `/statuslist/{id}`, unauthenticated, and the URI of the list is written into
536555
every credential issued from it.
537556

557+
That URI is absolute and fixed at the moment of issuance, so **changing the deployment's base URL
558+
strands every credential issued before the change**: the wallet resolves the URI it was given, which
559+
still names the old origin. There is nothing to rewrite — the credential is signed, and the copy that
560+
matters is in someone else's wallet. If the base URL has to change, keep the old origin answering, by
561+
alias or redirect, for as long as any credential issued under it can still be presented. The same
562+
applies to moving the module to a different path.
563+
538564
This endpoint keeps serving when `OPTION_VCI_STATUS_LIST_ENABLED` is switched off. Turning the switch
539565
off stops new credentials getting an entry allocated; it does not, and must not, strand the credentials
540566
already in wallets as unverifiable.
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace SimpleSAML\Test\Module\oidc\unit\Controllers\VerifiableCredentials;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\MockObject\MockObject;
9+
use PHPUnit\Framework\TestCase;
10+
use SimpleSAML\Module\oidc\Controllers\VerifiableCredentials\CredentialIssuerConfigurationController;
11+
use SimpleSAML\Module\oidc\ModuleConfig;
12+
use SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException;
13+
use SimpleSAML\Module\oidc\Services\LoggerService;
14+
use SimpleSAML\Module\oidc\Utils\Routes;
15+
use SimpleSAML\Module\oidc\Utils\VciContextResolver;
16+
use SimpleSAML\OpenID\Algorithms\SignatureAlgorithmEnum;
17+
use SimpleSAML\OpenID\Codebooks\ClaimsEnum;
18+
use SimpleSAML\OpenID\Codebooks\CredentialFormatIdentifiersEnum;
19+
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPair;
20+
use SimpleSAML\OpenID\ValueAbstracts\SignatureKeyPairBag;
21+
use Symfony\Component\HttpFoundation\JsonResponse;
22+
23+
#[CoversClass(CredentialIssuerConfigurationController::class)]
24+
class CredentialIssuerConfigurationControllerTest extends TestCase
25+
{
26+
protected const string CONFIGURATION_ID = 'UniversityDegreeCredential';
27+
28+
protected const string ISSUER = 'https://issuer.com';
29+
30+
protected const string CREDENTIAL_ENDPOINT = 'https://issuer.com/credential';
31+
32+
protected const string NONCE_ENDPOINT = 'https://issuer.com/nonce';
33+
34+
/**
35+
* Every getter through which a private Status List control could reach this controller.
36+
*
37+
* The published document is wallet visible, and these settings are not: which pool a configuration
38+
* allocates from, how its lists are keyed, how long its credentials live, and how the lists are
39+
* wound down are the deployment's business and say something about its revocation practice. They
40+
* are kept in top level options precisely so that returning `credential_configurations_supported`
41+
* wholesale can not carry them out, and this list is what checks that nothing started reading them
42+
* here.
43+
*/
44+
protected const array PRIVATE_STATUS_LIST_GETTERS = [
45+
'getVciStatusListEnabled',
46+
'getVciStatusListPoolBag',
47+
'getVciStatusListPoolFor',
48+
'getVciStatusListKeyProfile',
49+
'getVciCredentialTtls',
50+
'getVciCredentialTtlFor',
51+
'getVciStatusListRetirementGrace',
52+
'getVciStatusListAuditRetention',
53+
'getVciStatusListRequestsPerMinute',
54+
];
55+
56+
protected MockObject $moduleConfigMock;
57+
protected MockObject $routesMock;
58+
protected MockObject $loggerServiceMock;
59+
protected MockObject $vciContextResolverMock;
60+
61+
protected function setUp(): void
62+
{
63+
$this->moduleConfigMock = $this->createMock(ModuleConfig::class);
64+
$this->routesMock = $this->createMock(Routes::class);
65+
$this->loggerServiceMock = $this->createMock(LoggerService::class);
66+
$this->vciContextResolverMock = $this->createMock(VciContextResolver::class);
67+
68+
$this->moduleConfigMock->method('getVciEnabled')->willReturn(true);
69+
$this->moduleConfigMock->method('getIssuer')->willReturn(self::ISSUER);
70+
$this->moduleConfigMock->method('getOrganizationName')->willReturn('Example University');
71+
$this->moduleConfigMock->method('getDescription')->willReturn('Example credentials');
72+
$this->moduleConfigMock->method('getLogoUri')->willReturn('https://issuer.com/logo.png');
73+
$this->moduleConfigMock->method('getVciCredentialConfigurationsSupported')
74+
->willReturn($this->credentialConfigurations());
75+
76+
$signatureKeyPairMock = $this->createMock(SignatureKeyPair::class);
77+
$signatureKeyPairMock->method('getSignatureAlgorithm')->willReturn(SignatureAlgorithmEnum::ES256);
78+
$signatureKeyPairBagMock = $this->createMock(SignatureKeyPairBag::class);
79+
$signatureKeyPairBagMock->method('getFirstOrFail')->willReturn($signatureKeyPairMock);
80+
$this->moduleConfigMock->method('getVciSignatureKeyPairBag')->willReturn($signatureKeyPairBagMock);
81+
82+
$this->routesMock->method('urlCredentialIssuerCredential')->willReturn(self::CREDENTIAL_ENDPOINT);
83+
$this->routesMock->method('urlCredentialIssuerNonce')->willReturn(self::NONCE_ENDPOINT);
84+
$this->routesMock->method('newJsonResponse')->willReturnCallback(
85+
/**
86+
* @param ?array<array-key,mixed> $data
87+
*/
88+
static fn(?array $data = null): JsonResponse => new JsonResponse($data),
89+
);
90+
}
91+
92+
/**
93+
* @return array<string,array<string,mixed>>
94+
*/
95+
protected function credentialConfigurations(): array
96+
{
97+
return [
98+
self::CONFIGURATION_ID => [
99+
ClaimsEnum::Format->value => CredentialFormatIdentifiersEnum::JwtVcJson->value,
100+
ClaimsEnum::Scope->value => 'UniversityDegree',
101+
],
102+
];
103+
}
104+
105+
/**
106+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
107+
*/
108+
protected function sut(): CredentialIssuerConfigurationController
109+
{
110+
return new CredentialIssuerConfigurationController(
111+
$this->moduleConfigMock,
112+
$this->routesMock,
113+
$this->loggerServiceMock,
114+
$this->vciContextResolverMock,
115+
);
116+
}
117+
118+
/**
119+
* @return array<array-key,mixed>
120+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
121+
* @throws \JsonException
122+
*/
123+
protected function publishedMetadata(): array
124+
{
125+
$content = $this->sut()->configuration()->getContent();
126+
127+
$this->assertIsString($content);
128+
129+
/** @var array<array-key,mixed> $decoded */
130+
$decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
131+
132+
return $decoded;
133+
}
134+
135+
/**
136+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
137+
* @throws \JsonException
138+
*/
139+
public function testPublishesTheIssuerAndItsEndpoints(): void
140+
{
141+
$metadata = $this->publishedMetadata();
142+
143+
$this->assertSame(self::ISSUER, $metadata[ClaimsEnum::CredentialIssuer->value]);
144+
$this->assertSame(self::CREDENTIAL_ENDPOINT, $metadata[ClaimsEnum::CredentialEndpoint->value]);
145+
$this->assertSame(self::NONCE_ENDPOINT, $metadata[ClaimsEnum::NonceEndpoint->value]);
146+
}
147+
148+
/**
149+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
150+
* @throws \JsonException
151+
*/
152+
public function testDescribesWhatEachConfigurationCanBeProvedAndSignedWith(): void
153+
{
154+
$metadata = $this->publishedMetadata();
155+
156+
/** @var array<string,array<string,mixed>> $configurations */
157+
$configurations = $metadata[ClaimsEnum::CredentialConfigurationsSupported->value];
158+
$configuration = $configurations[self::CONFIGURATION_ID];
159+
160+
$this->assertSame(
161+
[SignatureAlgorithmEnum::ES256->value],
162+
$configuration[ClaimsEnum::CredentialSigningAlgValuesSupported->value],
163+
);
164+
$this->assertSame(
165+
['did:key', 'did:jwk'],
166+
$configuration[ClaimsEnum::CryptographicBindingMethodsSupported->value],
167+
);
168+
$this->assertArrayHasKey(ClaimsEnum::ProofTypesSupported->value, $configuration);
169+
// What the operator configured is still there, with the above added rather than substituted.
170+
$this->assertSame('UniversityDegree', $configuration[ClaimsEnum::Scope->value]);
171+
}
172+
173+
/**
174+
* The document goes to wallets, so nothing about how this deployment runs its Status Lists may be
175+
* in it.
176+
*
177+
* `credential_configurations_supported` is republished wholesale, so a private control placed
178+
* inside one would be handed out with it. That is why they are top level options instead, and this
179+
* asserts the arrangement rather than trusting it: no getter which could carry one is reached
180+
* while the document is built.
181+
*
182+
* @throws \SimpleSAML\Module\oidc\Server\Exceptions\OidcServerException
183+
* @throws \JsonException
184+
*/
185+
public function testPublishesNoStatusListControls(): void
186+
{
187+
foreach (self::PRIVATE_STATUS_LIST_GETTERS as $getter) {
188+
$this->moduleConfigMock->expects($this->never())->method($getter);
189+
}
190+
191+
$metadata = $this->publishedMetadata();
192+
193+
$encoded = json_encode($metadata, JSON_THROW_ON_ERROR);
194+
195+
foreach (
196+
[
197+
ModuleConfig::OPTION_VCI_STATUS_LIST_ENABLED,
198+
ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE,
199+
ModuleConfig::OPTION_VCI_STATUS_LIST_POOLS,
200+
ModuleConfig::OPTION_VCI_STATUS_LIST_REQUESTS_PER_MINUTE,
201+
ModuleConfig::OPTION_VCI_STATUS_LIST_RETIREMENT_GRACE,
202+
ModuleConfig::OPTION_VCI_STATUS_LIST_AUDIT_RETENTION,
203+
ModuleConfig::OPTION_VCI_CREDENTIAL_TTLS,
204+
] as $option
205+
) {
206+
$this->assertStringNotContainsString($option, $encoded);
207+
}
208+
209+
// The specification registers no "status lists are supported" parameter, and support is
210+
// discovered from the `status` claim of an issued credential instead. Anything resembling one
211+
// here would be invented rather than published.
212+
$this->assertStringNotContainsString('status_list', $encoded);
213+
}
214+
215+
/**
216+
* The constructor is the gate: with Verifiable Credentials switched off there is no metadata to
217+
* publish, and nothing further in this controller should be reachable.
218+
*/
219+
public function testRefusesToPublishAnythingWhenCredentialsAreDisabled(): void
220+
{
221+
$moduleConfigMock = $this->createMock(ModuleConfig::class);
222+
$moduleConfigMock->method('getVciEnabled')->willReturn(false);
223+
$moduleConfigMock->expects($this->never())->method('getVciCredentialConfigurationsSupported');
224+
225+
$this->loggerServiceMock->expects($this->once())->method('warning');
226+
227+
$this->expectException(OidcServerException::class);
228+
229+
new CredentialIssuerConfigurationController(
230+
$moduleConfigMock,
231+
$this->routesMock,
232+
$this->loggerServiceMock,
233+
$this->vciContextResolverMock,
234+
);
235+
}
236+
}

0 commit comments

Comments
 (0)