Skip to content

Commit 5be0073

Browse files
committed
Disable Rector parallel mode and set 1024M memory limit to prevent CI worker crashes
1 parent fe06619 commit 5be0073

18 files changed

Lines changed: 867 additions & 53 deletions

‎config/module_oidc.php.dist‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,11 @@ $config = [
19131913
* - DidJwk: `kid` is the issuer's `did:jwk:...#0` and `iss` is the same
19141914
* `did:jwk:...`. The token carries the key with it, so it verifies without
19151915
* any external lookup. This is the default.
1916+
* - DidWeb: `iss` is the did:web set under
1917+
* OPTION_VCI_ISSUER_DID_IDENTIFIER and `kid` names the key in the DID
1918+
* document this module publishes for it, matching how credentials are
1919+
* identified under the same issuer identity. Requires that option to be
1920+
* set, and requires the published document to stay reachable.
19161921
* - Jwks: `iss` is this module's issuer URL and `kid` is the JWKS key ID,
19171922
* so the key is resolved through the published JWKS. Use this for Relying
19181923
* Parties which will not accept a `did:jwk` key identifier.
@@ -1922,6 +1927,19 @@ $config = [
19221927
* lists keep being served under the profile their holders already resolved
19231928
* them by. Changing it therefore never invalidates credentials which are
19241929
* already in wallets.
1930+
*
1931+
* A list created under DidWeb also records the identifier itself, for the
1932+
* same reason it records its signing key: changing or clearing
1933+
* OPTION_VCI_ISSUER_DID_IDENTIFIER routes new credentials to new lists
1934+
* rather than changing the issuer of tokens already being verified. Keep the
1935+
* DID document published for every identifier any unretired list still
1936+
* names. The administration screen lists the identities credentials were
1937+
* issued under, which covers these too unless credentials are issued under
1938+
* some other identity than the Status Lists are.
1939+
*
1940+
* Note that a deployment which creates a DidWeb list can not be rolled back
1941+
* to a release without this profile: an older process reading such a row
1942+
* fails rather than guessing an identity for it.
19251943
*/
19261944
ModuleConfig::OPTION_VCI_STATUS_LIST_KEY_PROFILE => \SimpleSAML\Module\oidc\Codebooks\StatusListKeyProfileEnum::DidJwk,
19271945

‎docs/3-oidc-configuration.md‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,13 +860,39 @@ key it was signed with is a deployment choice:
860860

861861
- `did_jwk` (default): `kid` is the issuer's `did:jwk:...#0` and `iss` is the same `did:jwk:...`. The
862862
token carries the key with it and verifies without any external lookup.
863+
- `did_web`: `iss` is the `did:web` set under `OPTION_VCI_ISSUER_DID_IDENTIFIER` and `kid` names the
864+
signing key in the DID document this module publishes for it. This is the profile which makes a
865+
credential and the Status List Token it points at name the same issuer under the same resolvable
866+
identity. It requires that option to be set — a pool on this profile without one is a configuration
867+
error rather than something discovered when a token is signed.
863868
- `jwks`: `iss` is this module's issuer URL and `kid` is a JWKS key ID, so the key is resolved through
864869
the published JWKS. Use this for Relying Parties which will not accept a `did:jwk` key identifier.
865870

866871
Each list records the profile it was created under. Changing the setting therefore routes newly issued
867872
credentials to newly created lists, while existing lists keep being served under the profile their
868873
holders already resolved them by — so changing it never invalidates anything already in a wallet.
869874

875+
**A `did_web` list also records the identifier, not just the profile.** It has to: unlike the other two,
876+
that identifier is a setting of its own which can be changed or cleared while lists created under it are
877+
still being served. Reading it afresh at signing time would silently rewrite the `iss` of every token
878+
those lists emit, leaving a wallet holding a credential naming one issuer and a status token naming
879+
another. Because the identifier is recorded, changing it behaves like every other policy change: new
880+
credentials go to new lists, and the old lists go on naming the issuer they were created under.
881+
882+
The obligation that follows is the same one credentials carry. **Keep the DID document published for
883+
every identifier an unretired list still names**, not only for the one currently configured — a Relying
884+
Party checking a credential's status has to resolve the token's issuer, and a `404` there is
885+
indistinguishable from a revoked deployment. The Verifiable Credential configuration screen lists the
886+
identities **credentials** were issued under, which answers this too whenever credentials are issued
887+
under the same `did:web`. It does not cover the case where they are not — a deployment naming its
888+
credentials some other way while its Status Lists use `did:web` has to track the identifiers of its
889+
unretired lists itself.
890+
891+
**Selecting `did_web` is a one-way upgrade.** Once a list has been created under it, the deployment can
892+
no longer be rolled back to a release that predates the profile: an older process reading that row
893+
refuses it rather than guessing an identity, which takes that list's endpoint down. Roll the code
894+
forward everywhere before switching a pool onto it.
895+
870896
**A signing key has to outlive every list signed with it.** Each list records the key it was created
871897
with and is re-signed from that key alone, never from whichever key is current. Rotating keys is
872898
therefore safe in itself: new lists take the new key, existing lists keep theirs. Removing the old key
@@ -883,7 +909,9 @@ when the list matters most.
883909
Whether credentials stay verifiable in the meantime depends on the profile. A `did_jwk` token carries
884910
its own key, so tokens already published keep verifying. Under `jwks` the key is resolved through this
885911
module's published JWKS, which the same removal empties, so already published tokens stop verifying too
886-
once Relying Parties refetch it. A key is only safe to discard once every list it signed has been
912+
once Relying Parties refetch it. `did_web` behaves the same way as `jwks` here: the key is named in the
913+
published DID document, which is built from the configured keys, so removing one withdraws it there
914+
too. A key is only safe to discard once every list it signed has been
887915
retired, which the lifecycle below does only after the last credential in those lists has expired.
888916

889917
### Credential expiry

‎src/Codebooks/StatusListKeyProfileEnum.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ enum StatusListKeyProfileEnum: string
2727
*/
2828
case DidJwk = 'did_jwk';
2929

30+
/**
31+
* `iss` is the deployment's configured `did:web`, and `kid` the verification method that DID's
32+
* published document names for the signing key, matching how Verifiable Credentials are identified
33+
* under the same issuer identity. Unlike `did_jwk`, the token does not carry its key: a Relying
34+
* Party resolves the document over HTTPS, which is what lets the same issuer name outlive any one
35+
* key. Requires a configured issuer `did:web` identifier, whose document this module publishes.
36+
*
37+
* The identifier is recorded on each list at creation, so a list keeps naming the issuer it was
38+
* created under even after the configured one changes -- and that document therefore has to stay
39+
* resolvable for as long as any credential pointing at the list is still being verified.
40+
*/
41+
case DidWeb = 'did_web';
42+
3043
/**
3144
* `iss` is the module's issuer URL and `kid` is the JWKS key ID, so a Relying Party resolves the
3245
* key through the issuer's published JWKS. Use this for Relying Parties which will not accept a

‎src/Controllers/JwksController.php‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ public function __invoke(): JsonResponse
7070
* the database did, taking down verification of every ID token and access token this issuer has
7171
* ever signed. That is a far larger failure than the one it would prevent.
7272
*
73+
* Asked of the raw configured values rather than of the built pools, which matters for the same
74+
* reason. Building them is all or nothing: one pool misconfigured in any way -- a bad bit count, or
75+
* an issuer `did:web` which only another pool's profile even reads -- would make the whole bag
76+
* unreadable, this question answer "no", and the key that a perfectly good `jwks` pool's already
77+
* published tokens are verified through be withdrawn over a setting those tokens never used.
78+
*
7379
* The gap this leaves is an operator removing a pool, or switching it to the other key profile,
7480
* while lists created under the old one are still being served. That is the same class of change as
7581
* removing the signing key itself, and it is caught where it can be acted on: publication resolves
@@ -78,19 +84,14 @@ public function __invoke(): JsonResponse
7884
protected function isAnyStatusListKeyPublished(): bool
7985
{
8086
try {
81-
foreach ($this->moduleConfig->getVciStatusListPoolBag()->getAll() as $pool) {
82-
if ($pool->getKeyProfile() === StatusListKeyProfileEnum::Jwks) {
83-
return true;
84-
}
85-
}
87+
return $this->moduleConfig->isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum::Jwks);
8688
} catch (Throwable) {
87-
// A pool which can not be resolved is reported on the configuration overview screen, which
88-
// owns that error. Here the conservative reading is that no pool needs its key published,
89-
// which leaves the key set exactly as it was before Status Lists existed.
89+
// Only a pool list or a default key profile which can not be read at all reaches here, and
90+
// both are reported on the configuration overview screen, which owns that error. The
91+
// conservative reading is that no pool needs its key published, which leaves the key set
92+
// exactly as it was before Status Lists existed.
9093
return false;
9194
}
92-
93-
return false;
9495
}
9596

9697

‎src/ModuleConfig.php‎

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1829,6 +1829,67 @@ public function getVciStatusListKeyProfile(): StatusListKeyProfileEnum
18291829
}
18301830

18311831

1832+
/**
1833+
* Whether any configured Status List pool signs under the given key profile.
1834+
*
1835+
* Answered from the raw configured values rather than by building the pools, and that is the point
1836+
* of it. The JWKS endpoint asks this to decide whether the credential signing key still has to be
1837+
* published, and a pool misconfigured in any way at all would otherwise make the whole bag
1838+
* unreadable and the answer "no" - withdrawing the key that a different, perfectly good pool's
1839+
* already published tokens are verified through. A question about one pool must not be answerable
1840+
* only while every pool is valid.
1841+
*
1842+
* The pool bag asks it too, before resolving the issuer `did:web`, so that an option only the
1843+
* `did_web` profile reads is not consulted for a deployment which has no pool on it.
1844+
*
1845+
* A value which is not a valid profile at all counts as "no": the pool is about to be refused by
1846+
* name, which is a better error than one about whichever profile was being asked about.
1847+
*
1848+
* @param array<array-key,mixed> $pools
1849+
*/
1850+
protected function isAnyStatusListPoolOnKeyProfileIn(
1851+
array $pools,
1852+
StatusListKeyProfileEnum $keyProfile,
1853+
StatusListKeyProfileEnum $defaultKeyProfile,
1854+
): bool {
1855+
/** @var mixed $poolConfig */
1856+
foreach ($pools as $poolConfig) {
1857+
if (!is_array($poolConfig) || !array_key_exists(StatusListPool::KEY_KEY_PROFILE, $poolConfig)) {
1858+
if ($defaultKeyProfile === $keyProfile) {
1859+
return true;
1860+
}
1861+
1862+
continue;
1863+
}
1864+
1865+
/** @var mixed $configured */
1866+
$configured = $poolConfig[StatusListPool::KEY_KEY_PROFILE];
1867+
1868+
if ($configured === $keyProfile || $configured === $keyProfile->value) {
1869+
return true;
1870+
}
1871+
}
1872+
1873+
return false;
1874+
}
1875+
1876+
1877+
/**
1878+
* Whether any configured Status List pool signs under the given key profile.
1879+
*
1880+
* @throws \SimpleSAML\Error\ConfigurationError On a malformed default key profile, which is the one
1881+
* value this cannot be answered without.
1882+
*/
1883+
public function isAnyStatusListPoolOnKeyProfile(StatusListKeyProfileEnum $keyProfile): bool
1884+
{
1885+
return $this->isAnyStatusListPoolOnKeyProfileIn(
1886+
$this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []),
1887+
$keyProfile,
1888+
$this->getVciStatusListKeyProfile(),
1889+
);
1890+
}
1891+
1892+
18321893
/**
18331894
* The configured Status List pools.
18341895
*
@@ -1844,9 +1905,27 @@ public function getVciStatusListPoolBag(): StatusListPoolBag
18441905
return $this->vciStatusListPoolBag;
18451906
}
18461907

1908+
$pools = $this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []);
1909+
$defaultKeyProfile = $this->getVciStatusListKeyProfile();
1910+
18471911
$poolBag = StatusListPoolBag::fromConfig(
1848-
$this->config()->getOptionalArray(self::OPTION_VCI_STATUS_LIST_POOLS, []),
1849-
$this->getVciStatusListKeyProfile(),
1912+
$pools,
1913+
$defaultKeyProfile,
1914+
// The module wide issuer identity, which the `did_web` key profile stamps onto every list
1915+
// it creates. Taken from here rather than configured per pool, so that a deployment cannot
1916+
// end up with two issuer identities of which only one has a published DID document.
1917+
//
1918+
// Resolved only when some pool is actually on that profile. Reading it unconditionally
1919+
// would mean a malformed did:web identifier -- an option no other profile looks at -- also
1920+
// stopping the pools from being read, and the JWKS endpoint answers "does any pool need its
1921+
// key published?" by reading them. A pool on the `jwks` profile would then have the key its
1922+
// already published tokens are verified through quietly withdrawn, over a setting it does
1923+
// not use.
1924+
$this->isAnyStatusListPoolOnKeyProfileIn(
1925+
$pools,
1926+
StatusListKeyProfileEnum::DidWeb,
1927+
$defaultKeyProfile,
1928+
) ? $this->getVciIssuerDidIdentifier() : null,
18501929
);
18511930

18521931
$supportedIds = $this->getVciCredentialConfigurationIdsSupported();

‎src/Repositories/StatusListRepository.php‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -323,19 +323,20 @@ public function create(
323323
int $refreshIntervalSeconds,
324324
string $signingKeyId,
325325
StatusListKeyProfileEnum $keyProfile,
326+
?string $issuerIdentifier = null,
326327
): void {
327328
$this->database->write(
328329
sprintf(
329330
'INSERT INTO %s (
330331
id, uri, pool_id, policy_fingerprint, expiry_lane, generation, bits, capacity,
331332
allowed_statuses, ttl_seconds, token_validity_seconds, refresh_interval_seconds,
332-
signing_key_id, key_profile, allocated_count, is_active,
333+
signing_key_id, key_profile, issuer_identifier, allocated_count, is_active,
333334
signed_token_content_hash, created_at
334335
) VALUES (
335336
:id, :uri, :pool_id, :policy_fingerprint, :expiry_lane, :generation, :bits,
336337
:capacity, :allowed_statuses, :ttl_seconds, :token_validity_seconds,
337-
:refresh_interval_seconds, :signing_key_id, :key_profile, :allocated_count,
338-
:is_active, :signed_token_content_hash, :created_at
338+
:refresh_interval_seconds, :signing_key_id, :key_profile, :issuer_identifier,
339+
:allocated_count, :is_active, :signed_token_content_hash, :created_at
339340
)',
340341
$this->getTableName(),
341342
),
@@ -354,6 +355,9 @@ public function create(
354355
'refresh_interval_seconds' => [$refreshIntervalSeconds, PDO::PARAM_INT],
355356
'signing_key_id' => $signingKeyId,
356357
'key_profile' => $keyProfile->value,
358+
// Null under every profile but `did_web`, which is the only one naming the issuer by
359+
// something a list has to remember rather than derive.
360+
'issuer_identifier' => $issuerIdentifier,
357361
'allocated_count' => [0, PDO::PARAM_INT],
358362
// Created inactive, and activated only once every index has been seeded. Otherwise a
359363
// concurrent request could select this list and probe indices which do not exist yet,

‎src/Services/DatabaseMigration.php‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,11 @@ public function migrate(): void
272272
$this->version20260902000001();
273273
$this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260902000001')");
274274
}
275+
276+
if (!in_array('20260903000001', $versions, true)) {
277+
$this->version20260903000001();
278+
$this->database->write("INSERT INTO $versionsTablename (version) VALUES ('20260903000001')");
279+
}
275280
}
276281

277282

@@ -1157,6 +1162,36 @@ private function version20260902000001(): void
11571162
}
11581163

11591164

1165+
/**
1166+
* The issuer identifier a Status List was created under.
1167+
*
1168+
* Only the `did_web` key profile fills it in, and only that profile needs it: a `did:jwk` is
1169+
* derived from the signing key the row already records, and the `jwks` profile names the issuer by
1170+
* a URL which cannot move without the key set moving with it. A `did:web` identifier is neither --
1171+
* it is a setting of its own, which an operator can change or clear while lists created under it
1172+
* are still being served. Recording it is what keeps such a change from rewriting the `iss` of
1173+
* every token those lists emit, which would leave a wallet holding a credential naming one issuer
1174+
* and a Status List Token naming another.
1175+
*
1176+
* Nullable, so it can be added to a table which already has rows: every existing list is on a
1177+
* profile which does not use it.
1178+
*/
1179+
private function version20260903000001(): void
1180+
{
1181+
$statusListTableName = $this->database->applyPrefix(StatusListRepository::TABLE_NAME);
1182+
1183+
if ($this->hasColumn($statusListTableName, 'issuer_identifier')) {
1184+
return;
1185+
}
1186+
1187+
$this->database->write(<<< EOT
1188+
ALTER TABLE {$statusListTableName}
1189+
ADD issuer_identifier TEXT NULL
1190+
EOT
1191+
,);
1192+
}
1193+
1194+
11601195
/**
11611196
* Whether a table already has a column.
11621197
*

‎src/StatusList/DbStatusIndexAllocator.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ protected function createList(
477477
$pool->getRefreshIntervalInSeconds(),
478478
$signingKeyId,
479479
$pool->getKeyProfile(),
480+
$pool->getIssuerIdentifier(),
480481
);
481482
} catch (Throwable $throwable) {
482483
$this->loggerService->info(

0 commit comments

Comments
 (0)