# Trusted lists

A wallet connection can take its issuer trust anchors from **published, signed trusted lists** that HelixIAM fetches, verifies and refreshes itself, instead of (or as well as) PEM certificates you paste in. A source is set up once per realm; the [wallet connection](eudi-wallet.md) and each of its [other credentials](wallet-credentials.md) name the sources they trust. (Beta; signer rotation and LoTE pointers are Preview, not yet tested with a real national wallet.)

!!! warning "Status"
    Trusted lists are **Beta**: verified against the published EUDI test list of trusted lists and the European Commission's acceptance List of Trusted Entities. The automation added in 1.1.0-rc.1 — **signer rotation** (the LOTL "pivot"), **LoTE pointers** and nested lists — is **Preview**: implemented from the ETSI specifications and tested with generated lists only, because no published pivot LOTL or LoTE pointer was available to test against. Nothing here has been tested with a real national wallet yet.

Console: **Federation & eIDs → Trusted lists**. Admin API: `/admin/realms/{realm}/trusted-lists`. Reads need `view-identity-providers`; creating, updating, deleting, refreshing and resetting need `manage-identity-providers`.

## Formats and what is published

Helix reads both formats the EU publishes for wallet trust:

- **ETSI TS 119 612 XML**: a trusted list, or a list of trusted lists (LOTL) whose pointers are followed;
- **ETSI TS 119 602 List of Trusted Entities (LoTE)**, JSON in a JWS, whose `PointersToOtherLoTE` can be followed too.

What is published, as checked by the project on 15 October 2026:

| Source | Format | Status |
| --- | --- | --- |
| EUDI Wallet reference implementation, `https://trustedlist.serviceproviders.eudiw.dev/LOTL/01.xml` | TS 119 612 XML, a list of trusted lists | **Test only.** PID providers, wallet providers, access-certificate CAs, (Q)EAA providers |
| European Commission, `https://acceptance.trust.tech.ec.europa.eu/lists/eudiw/pid-providers.json` | TS 119 602 LoTE (JWS) | **Acceptance environment.** No production list of PID providers is published yet |
| EU list of trusted lists, `https://ec.europa.eu/tools/lotl/eu-lotl.xml` | TS 119 612 XML | Production, but it does **not** list PID providers |

Nothing is specific to one publisher: the URL, the pinned signer certificates, the service types and the accepted statuses are configuration, so a list published later in either format works without code changes.

## Add a source

A source has:

| Field | Meaning |
| --- | --- |
| `name` | what wallet connections refer to |
| `url` | the `https` URL of the list. No redirect is followed: configure the final URL |
| `format` | `ETSI_119612_XML` or `ETSI_119602_JSON` |
| `signerCertificates` | the **pinned signer certificates** (PEM) |
| `serviceTypes` | the service types to extract, for example `http://uri.etsi.org/Svc/Svctype/Provider/PID` |
| `acceptedStatuses` | the statuses that count; default `granted` and `recognisedatnationallevel` (`withdrawn` and `deprecatedatnationallevel` can never be chosen) |
| `territories` | for a list of lists, optionally the territories whose lists are read (`NL`, `DE`, …) |
| `graceHours` | how long the last good anchors are kept past NextUpdate; default 24, at most 720 |
| `signerRotation` | TS 119 612 lists of lists only, default off: accept a new signer the verified list announces ([signer rotation](#signer-rotation-the-lotl-pivot)). The console's LOTL presets turn it on |
| `pointerDepth` | 0–3: how many levels of pointers to other lists are followed ([pointers](#pointers-to-other-lists)). Unset: 1 for XML, 0 for LoTE |
| `enabled` | the switch |

```bash
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/trusted-lists" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

jq -n --rawfile signer lotl-signer.pem '{
  name: "eudi-pid-providers",
  url: "https://trustedlist.serviceproviders.eudiw.dev/LOTL/01.xml",
  format: "ETSI_119612_XML",
  signerCertificates: $signer,
  serviceTypes: ["http://uri.etsi.org/Svc/Svctype/Provider/PID"],
  signerRotation: true
}' > trusted-list.json

curl -s -b cookies.txt \
  -H "Content-Type: application/json" \
  -H "X-XSRF-TOKEN: $CSRF" \
  -d @trusted-list.json \
  "$HELIX_URL/admin/realms/$REALM/trusted-lists"
```

The answer is `201` with the source, including its `id`, `configVersion` and `status`. Load it at once with **Refresh now** in the console, or:

```bash
curl -s -b cookies.txt -X POST -H "X-XSRF-TOKEN: $CSRF" \
  "$HELIX_URL/admin/realms/$REALM/trusted-lists/$LIST_ID/refresh"
```

The answer is the source as it is after the attempt; its `status` says how it went. `409` means another refresh holds the source.

Then name the source on the wallet connection, optionally narrowed by service type:

```json
"config": {
  "accessKeyRef": "software:master/primary",
  "trustedLists.sources": "[\"eudi-pid-providers\"]",
  "trustedLists.serviceTypes": "[\"http://uri.etsi.org/Svc/Svctype/Provider/PID\"]"
}
```

The anchors are read at every verification, so a refresh, a withdrawal or an expiry takes effect at once. Pasted `trustAnchors` are then optional; when both are set, they are combined.

**Editing.** Every update (`PUT /admin/realms/{realm}/trusted-lists/{id}`) must send the `configVersion` of the source it edited. When another admin saved in between, the update is a `409` (the console offers to reload) instead of overwriting their change. Renaming or deleting a source that a wallet connection names, for the PID or for any [other credential](wallet-credentials.md), is refused with `409`: remove it from the connections first. Changing what a source loads (URL, format, signers, types, statuses, territories, signer rotation, pointer depth) drops its stored anchors at once.

!!! danger "Pin the signer through a channel you trust"
    The signer certificates must come from the publisher through a channel you trust, never from the list itself. For the EU list of trusted lists that is the Official Journal publication of the LOTL signing certificates. The console's presets fill in the URL, format and service types, never a signer.

## What is verified

A list counts only when:

- it was fetched over `https` through the outbound guard (the connection goes to the checked address, TLS is verified against the host name, no redirect is followed; at most 16 MB; connect 5 s, 30 s in total);
- its signature verifies with **one of the pinned signer certificates**, or with signers that a verified list announced ([signer rotation](#signer-rotation-the-lotl-pivot)) — never with the certificate the list carries in its own `KeyInfo` or `x5c`. The signer must have an RSA (2048 bits or more) or EC (P-256 or stronger) key and must have been valid at the list's issue time;
- it was parsed without a DOCTYPE (so no entities), without XInclude or external DTD or schema access, at most 64 levels deep;
- its `NextUpdate` has not passed (a list without one is refused), counting it at most until its issue time plus six months;
- its sequence number is not lower than that of the last list accepted (rollback protection).

XML signatures must be one enveloped signature over the whole list, RSA PKCS#1 v1.5, RSASSA-PSS or ECDSA with SHA-256/384/512 digests; JSON lists RS/PS/ES 256/384/512, never `alg=none`.

From a verified list Helix keeps every service of the configured types whose **current** status is accepted. Only CA certificates that pass the usual anchor rule (EC P-256/384/521 or RSA 2048 bits or more, `keyCertSign`) become anchors. A listed end-entity certificate is shown in the preview as "not a CA" and not used.

### Expired signers

Lists are signed when issued and stay valid until NextUpdate, so a publisher's signer certificate can expire first. How long after its expiry a signer may still verify a list issued while it was valid is set per deployment:

| Property | Default | Applies to |
| --- | --- | --- |
| `helix.wallet.trusted-lists.signer-expiry-grace-days` | `0` | an **admin-pinned signer of a top-level list** |
| `helix.wallet.trusted-lists.member-signer-expiry-grace-days` | `180` | a **member list's signer named by the verified list of lists** |

With the default `0`, a list whose pinned signer has expired is refused, and the console says so. As of 2026-10-15 the EUDI reference implementation's test LOTL (signer expired 2026-07-04) loads only with `helix.wallet.trusted-lists.signer-expiry-grace-days` set to about **120** or more, and the Commission's acceptance LoTE (signer expired 2026-09-15) with about **30** or more. Set it deliberately for such test lists; there is no lenient default, not even in `dev`:

```properties
helix.wallet.trusted-lists.signer-expiry-grace-days=120
```

## Pointers to other lists

A list of trusted lists, or a LoTE with `PointersToOtherLoTE`, points at other lists. Up to the source's `pointerDepth` (0–3; unset: 1 for XML, 0 for LoTE), each pointer to a list in the chosen territories is a **member**, judged on its own. A member must:

- be `https` (a plain-http pointer is skipped and reported);
- verify with the signer certificates **the parent list names for it**;
- carry the territory and list type the pointer names, and be fresh;
- not have a lower sequence number than the highest one accepted for its territory and list type (the high-water mark, kept per source even when the member's URL changes).

A member that fails keeps its own last good anchors until its own NextUpdate plus grace, then gives none; the other members are not affected, and the source shows `DEGRADED`. Members the list no longer points at are dropped. Every list is read at most once in a load (a pointer back to a list already read is a cycle and stops), at most 64 lists in all. A member's format follows the pointer's MIME type (JSON: LoTE, XML: TS 119 612). The services the top list lists itself also count, so a LoTE that lists entities and points at others gives both.

A whole load (the list, its pivots and every list it points at) has a wall-clock budget, `helix.wallet.trusted-lists.max-refresh-seconds` (300). Past it the refresh fails and the last good state is kept.

## Signer rotation (the LOTL pivot)

Without signer rotation, when a publisher rotates its signing key the list stops verifying: the source goes `FAILING`, then `STALE`, then `EXPIRED`, unless you pin the new certificate before the grace period ends.

With `signerRotation` on (TS 119 612 lists of lists only), one rule applies: a list is verified only with certificates that come from an admin pin or from a list that was itself verified.

- **Self-pointer.** The LOTL's pointer to itself names the certificates authorised to sign it. Once a verified LOTL names them, they replace the pins for the next refresh. During a rollover the publisher lists both the old and the new key, so both verify; a key it drops is no longer trusted.
- **Pivots.** A LOTL that does not verify with the trusted set may still verify through its pivot LOTLs: the `https` URIs in its `SchemeInformationURI` (at most 8 fetched). The links are taken in ascending sequence number, each verified with the keys trusted at that step, each after the last accepted list and before the new LOTL. Once a list has been accepted, a link's signer must also still be valid now, so an expired key cannot sign a backdated pivot. The LOTL must finally verify with the last set; otherwise the refresh fails with `SIGNATURE`.
- Rollback protection is unchanged: the LOTL's sequence number never goes down, and pivots give no anchors.

Every change of the trusted set is audited as `TRUSTED_LIST_SIGNER_ROTATED` with the old and new SHA-256 fingerprints; a member whose parent names other signers than at the last load is audited the same way. The console shows the certificate that verified the last list and the announced signers.

!!! note "Not checked"
    The Official Journal reference among the `SchemeInformationURI` values is not compared with a configured value.

## Refresh, backoff and failing closed

Each replica looks for due sources every minute; the one that takes the source's lease refreshes it, the others wait and then reload the stored result. A healthy source is refreshed at the earlier of its NextUpdate and the refresh interval (6 hours) after the last refresh.

A failed refresh is retried with **exponential backoff**: 15 minutes after the first failure, doubling with each failure in a row (30, 60, …) up to 6 hours, but never later than the moment the last good anchors stop being used. A success resets the count. The console shows the failures in a row and the next attempt.

| Status | Anchors used | When |
| --- | --- | --- |
| `OK` | yes | the last refresh verified a list whose NextUpdate has not passed |
| `FAILING` | yes (last good) | the last refresh failed, but the last good list is before its NextUpdate |
| `STALE` | yes (last good) | past the last good list's NextUpdate, within the grace period |
| `EXPIRED` | **no** | past NextUpdate plus grace: fail closed until a new list verifies |
| `DEGRADED` | yes (the good members) | a list of lists that is itself fine, with some members failing, stale, expired or never verified |
| `NEVER` / `ERROR` | no | never loaded / never verified |
| `INTEGRITY` | **no** | the stored settings or trust state failed their integrity check ([below](#tampering-and-reset-and-reload)) |
| `DISABLED` | no | switched off |

**Failing closed, with a clear code.** When a source gives no anchors (`EXPIRED`, `NEVER`, `ERROR`, `INTEGRITY`, `DISABLED`) and an issuer's chain reaches no other anchor of the connection, the presentation is refused with the audit reason `TRUST_LIST_UNAVAILABLE`, naming the source, instead of `ISSUER_UNTRUSTED`: the trust source, not the issuer, is the problem. The user sees "technical". An expired or not-yet-valid issuer certificate stays `ISSUER_UNTRUSTED`.

| Property | Default | Meaning |
| --- | --- | --- |
| `helix.wallet.trusted-lists.refresh-interval-minutes` | `360` | longest time between refreshes of a healthy source |
| `helix.wallet.trusted-lists.retry-interval-minutes` | `15` | wait after a failed refresh (doubles with each failure in a row) |
| `helix.wallet.trusted-lists.max-retry-interval-minutes` | `360` | longest wait after failed refreshes |
| `helix.wallet.trusted-lists.check-interval-seconds` | `60` | how often each replica looks for due sources (`0`: only Refresh now) |
| `helix.wallet.trusted-lists.poll-interval-seconds` | `10` | how often each replica reloads sources that changed elsewhere |
| `helix.wallet.trusted-lists.max-refresh-seconds` | `300` | wall-clock budget of one refresh, every fetch included (at least 30) |
| `helix.wallet.trusted-lists.max-bytes` | `16777216` (16 MB) | largest list fetched |
| `helix.wallet.trusted-lists.max-validity-months` | `6` | a list counts at most this long after its issue time |

## Tampering and Reset and reload

Every stored value that decides trust is covered by an HMAC-SHA256 keyed from `DB_ENCRYPTION`, in two records: the source's **settings** (URL, format, pins, filters, grace, the switch, and the number of successful loads) and its **trust state** (anchors, sequence number, issue time, NextUpdate, announced signers and the members' high-water marks). Outside the `dev` profile a source cannot be saved without `DB_ENCRYPTION`.

**What is detected.** Against someone who can write the database but does not hold `DB_ENCRYPTION`: changing any single trust-deciding field (a pinned signer, a setting, an anchor, a sequence floor, the announced signers, a high-water mark), wiping the trust state of a loaded source, and setting the load counter back. The source then shows `INTEGRITY`, gives no anchors, and `TRUSTED_LIST_INTEGRITY_FAILED` is audited.

**What is not detected.** Replacing a source's rows with a complete, earlier copy of themselves, such as a database backup taken under the same `configVersion`. Every MAC in such a copy is valid, so the source goes back to that older list (a rollback). There is no anchor outside the database to compare against. A wallet connection's pasted `trustAnchors` are plain configuration and not covered either.

**Recovering.** Nothing of a row that failed its check is trusted or signed again: a refresh does not rebuild it, and saving the settings does not clear it.

- When the **settings** fail (modified outside Helix, stored before Flyway V84, or `DB_ENCRYPTION` changed): check them and save them again first.
- Then **Reset and reload**: the button on the failed list in the console (after a confirmation that explains the rollback floors are reset), or the admin API with the source's `configVersion`. It discards the stored anchors and floors and starts a clean first load from the pinned signers:

    ```bash
    curl -s -b cookies.txt -X POST \
      -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
      -d "{\"configVersion\": $CONFIG_VERSION}" \
      "$HELIX_URL/admin/realms/$REALM/trusted-lists/$LIST_ID/reset"
    ```

    It is refused with `409` while the settings themselves fail their check, when the source changed meanwhile, or while a refresh holds it. It is audited as `TRUSTED_LIST_RESET_REQUEST` (who) and `TRUSTED_LIST_RESET` (the previous sequence number and status).

The only other action that drops the rollback floors is a change of the list itself: another URL or format, or **removing a pinned signer**. Pins compare as certificate sets, so reformatting the PEM is no change, and adding a pin keeps the floors. Removing a pin needs `resetFloors: true` in the update (the console asks first), because the removed key may be the one that set the floors; without it the update is a `400`.

After **changing `DB_ENCRYPTION`**, check and save each source again, then Reset and reload it.

## Audit

Refreshes are audited as `TRUSTED_LIST_REFRESHED`, `TRUSTED_LIST_REFRESH_FAILED`, `TRUSTED_LIST_REFRESH_DISCARDED`, `TRUSTED_LIST_MEMBER_REFRESH_FAILED`, `TRUSTED_LIST_STALE`, `TRUSTED_LIST_EXPIRED`, `TRUSTED_LIST_MEMBER_STALE`, `TRUSTED_LIST_MEMBER_EXPIRED`, `TRUSTED_LIST_SIGNER_EXPIRING` / `TRUSTED_LIST_SIGNER_EXPIRED` (a pinned signer expiring within 30 days), `TRUSTED_LIST_SIGNER_ROTATED`, `TRUSTED_LIST_RESET` and `TRUSTED_LIST_INTEGRITY_FAILED`, with actor `system`. Admin writes are `TRUSTED_LIST_CREATE|UPDATE|DELETE|REFRESH_REQUEST|RESET_REQUEST`, with the name, URL and SHA-256 fingerprints of the pinned signers.

## Endpoints

| Method & path (under `/admin/realms/{realm}`) | Purpose |
| --- | --- |
| `GET /trusted-lists` · `POST /trusted-lists` | List / add sources |
| `GET` · `PUT` · `DELETE /trusted-lists/{id}` | Read / update (needs `configVersion`) / delete a source |
| `GET /trusted-lists/{id}/anchors` | The verified services of the source's last good list |
| `POST /trusted-lists/{id}/refresh` | Refresh a source now |
| `POST /trusted-lists/{id}/reset` | Reset and reload (needs `configVersion`) |

## Limitations

- Trusted list sources are not exported or imported with a realm: a realm import whose wallet connection names a source fails validation until the source exists in the target realm. There is no Terraform resource for sources.
- Signer rotation and LoTE pointers are tested with generated lists only.
- No production EU list of PID providers is published yet.

## See also

- [EU Digital Identity Wallet (OpenID4VP)](eudi-wallet.md) — the wallet connection that names these sources
- [Credentials beyond the PID](wallet-credentials.md) — per-credential trusted lists, for example (Q)EAA providers
- [Upgrading from rc.13 to 1.1.0-rc.1](../operations/upgrade-rc13-1.1.0-rc1.md) — Flyway V90 for this feature
