Verify the Trusted Nodes List
Before using a node from the Trusted Nodes List (TNL), verify the list: first the signatures, from the top of the chain of trust, then the content. New to the TNL? Read Trusted Nodes List.
If a check fails, keep the last list you accepted until it expires, and raise an alert. Never use a list that failed verification.
1. Pin the Europeum key-list key
trusted-keys.json is signed with the Europeum key-list signing key (OpenPGP, ECDSA P-256), used for nothing else. Pin its public key once, when you set up your application:
-
Get
keylist-key.ascfrom the Trusted Nodes List repository or athttps://tnl.ebsi.eu/keylist-key.asc. -
Check its primary key fingerprint, and compare it with the fingerprint Europeum publishes through another channel:
gpg --show-keys --with-fingerprint keylist-key.asc# ACCC 82E5 FC89 02F1 1492 9481 747C B65C FB75 74F9 -
Store the key and its fingerprint in your application's configuration.
Never download the key automatically at runtime, not even from /keylist-key.asc: whoever controls that download would decide which keys you trust.
2. Download the files
Download tnl.json, tnl.json.sig, trusted-keys.json and trusted-keys.json.sig from the Trusted Nodes List API. Verify the bytes exactly as received: any change, including re-formatting or line-ending conversion, breaks the signatures.
for f in tnl.json tnl.json.sig trusted-keys.json trusted-keys.json.sig; do
curl -fsS --proto =https -o "$f" "https://tnl.ebsi.eu/$f"
done
Signature rules
Steps 3 and 4 apply the same rules. A signature counts only if it:
- is valid for the exact file;
- was made by an allowed key, identified by its primary key fingerprint;
- uses ECDSA with SHA-256, SHA-384 or SHA-512, over a binary document.
Reject the file if any signature in it is invalid, even when the others are valid. A key that signs twice counts once.
With gpg, parse the status output (--status-fd 1) instead of the exit code, which can be 0 with too few valid signatures and varies between versions. Run each verification in a new, empty keyring (tempfile.mkdtemp()): --trust-model always is then safe, because valid_signers decides which keys count.
import json
import subprocess
def gpg(home: str, *args: str, stdin: bytes | None = None) -> list[list[str]]:
"""Run gpg in a throwaway keyring and return its status lines, split into fields."""
result = subprocess.run(
["gpg", "--batch", "--homedir", home, "--trust-model", "always", "--status-fd", "1", *args],
input=stdin,
capture_output=True,
)
return [
line.split()[1:]
for line in result.stdout.decode().splitlines()
if line.startswith("[GNUPG:] ")
]
def valid_signers(status: list[list[str]], allowed: set[str]) -> set[str]:
"""Allowed keys with a valid signature. Fails if any signature is bad."""
signers, good = set(), False
for f in status:
if f[0] == "BADSIG":
raise RuntimeError("a signature does not match the file")
if f[0] == "NEWSIG":
good = False
elif f[0] == "GOODSIG":
good = True
elif f[0] == "VALIDSIG" and good:
# VALIDSIG fpr date time expire version reserved pk-algo hash-algo class primary-fpr
primary = f[10] if len(f) > 10 else f[1]
if primary in allowed and f[7] == "19" and f[8] in ("8", "9", "10") and f[9] == "00":
signers.add(primary)
return signers
3. Verify trusted-keys.json
Check that trusted-keys.json.sig is signed by the key pinned in step 1:
gpg(home, "--import", stdin=PINNED_KEY)
status = gpg(home, "--verify", "trusted-keys.json.sig", "trusted-keys.json")
if not valid_signers(status, {PINNED_FINGERPRINT}):
raise RuntimeError("trusted-keys.json is not signed by Europeum")
Then read trusted-keys.json:
{
"version": 1,
"threshold": 1,
"keys": [
{
"fingerprint": "<custodian primary key fingerprint>",
"publicKey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n…\n-----END PGP PUBLIC KEY BLOCK-----\n"
}
]
}
| Field | Meaning |
|---|---|
version | Version of the key list. |
threshold | Number of different custodians that must sign tnl.json. |
keys | Primary key fingerprint and armored public key per custodian. |
4. Verify tnl.json
Import each custodian key and check that its fingerprint matches the one it is listed under. Then count the valid signatures: at least threshold different custodians must have signed. Signatures from unknown keys are ignored.
keys = json.loads(trusted_keys_bytes)
for key in keys["keys"]:
status = gpg(home, "--import", stdin=key["publicKey"].encode())
if {f[2] for f in status if f[0] == "IMPORT_OK"} != {key["fingerprint"]}:
raise RuntimeError("a public key does not match its fingerprint")
custodians = {key["fingerprint"] for key in keys["keys"]}
status = gpg(home, "--verify", "tnl.json.sig", "tnl.json")
if len(valid_signers(status, custodians)) < keys["threshold"]:
raise RuntimeError("not enough custodian signatures")
5. Check the content
Read tnl.json:
{
"version": 3,
"keysVersion": 1,
"issued": "2026-09-15T09:00:00Z",
"expires": "2026-12-14T09:00:00Z",
"environments": {
"pilot": ["https://api-pilot.ebsi.example-a.org", "https://api-pilot.ebsi.example-b.org"]
}
}
Accept the list only if every check passes:
| Check | Prevents |
|---|---|
| The file is valid against the JSON Schema | Malformed or unexpected content |
expires is in the future | Using a stale list |
issued is not in the future, with about 5 minutes of clock skew | Wrong clocks |
expires is at most 90 days after issued | Lists valid for too long |
keysVersion is lower than or equal to the version of trusted-keys.json | Missing signing keys |
version is higher than the last accepted version (or equal, for a byte-identical file) | Replay of an older list |
| Each URL starts with its environment's prefix, has no path or port, and appears once | A node posing as another environment |
| The environment you need is present | Having no node to use |
| Environment | URL prefix |
|---|---|
pilot | https://api-pilot.ebsi. |
preprod | https://api-preprod.ebsi. |
prod | https://api.ebsi. |
from datetime import datetime, timedelta, timezone
from urllib.parse import urlsplit
PREFIX = {
"pilot": "https://api-pilot.ebsi.",
"preprod": "https://api-preprod.ebsi.",
"prod": "https://api.ebsi.",
}
def instant(value: str) -> datetime:
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
tnl = json.loads(tnl_bytes)
now = datetime.now(timezone.utc)
issued, expires = instant(tnl["issued"]), instant(tnl["expires"])
if not (
issued <= now + timedelta(minutes=5)
and now < expires
and expires - issued <= timedelta(days=90)
):
raise RuntimeError("list is expired or not valid yet")
if tnl["keysVersion"] > keys["version"]:
raise RuntimeError("list needs a newer trusted-keys.json")
if tnl["version"] <= last_version and tnl_bytes != last_bytes:
raise RuntimeError("older or conflicting list version")
for env, urls in tnl["environments"].items():
if len(set(urls)) != len(urls):
raise RuntimeError(f"duplicate {env} node URL")
for url in urls:
if not url.startswith(PREFIX[env]) or url != f"https://{urlsplit(url).hostname}":
raise RuntimeError(f"invalid {env} node URL: {url}")
if ENVIRONMENT not in tnl["environments"]:
raise RuntimeError(f"{ENVIRONMENT} is not in the list")
Once the list is accepted, store its version and its bytes, or their SHA-256, to detect replays on the next run. On the first run there is nothing to compare against, so an older list can be accepted once, until it expires.
JSON Schema
Validate tnl.json against this schema (JSON Schema draft 2020-12), for example with jsonschema (Python), networknt/json-schema-validator (Java) or ajv (JavaScript). Keep the schema in your code rather than fetching it at runtime.
tnl.json schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "EBSI Trusted Nodes List",
"description": "Signed list of the approved EBSI node operators for the public environments. Serialised once, signed as-is; consumers must verify the detached OpenPGP signatures before trusting any field.",
"type": "object",
"properties": {
"version": {
"description": "Monotonically increasing list version. The first published version is 1. Consumers reject any version not greater than the last one they accepted.",
"type": "integer",
"minimum": 1
},
"keysVersion": {
"description": "Version of the trusted-keys document the custodian keys were taken from when this list was signed.",
"type": "integer",
"minimum": 1
},
"issued": {
"description": "UTC timestamp at which the list was built, second precision, Z suffix.",
"type": "string",
"format": "date-time",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$"
},
"expires": {
"description": "UTC timestamp after which the list must be rejected. Policy: issued + 90 days.",
"type": "string",
"format": "date-time",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$"
},
"environments": {
"description": "Per public environment, the API origins of its nodes. Internal environments (test, conformance) are out of scope.",
"type": "object",
"properties": {
"pilot": { "$ref": "#/$defs/environment" },
"preprod": { "$ref": "#/$defs/environment" },
"prod": { "$ref": "#/$defs/environment" }
},
"minProperties": 1,
"additionalProperties": false
}
},
"required": ["version", "keysVersion", "issued", "expires", "environments"],
"additionalProperties": false,
"$defs": {
"environment": {
"type": "array",
"items": {
"description": "Origin of a node's Core Services APIs. MUST start with https://api-{environment}.ebsi. (https://api.ebsi. on prod) and contain no path, query, port or credentials.",
"type": "string",
"format": "uri",
"pattern": "^https://[a-z0-9.-]+$"
},
"minItems": 1,
"uniqueItems": true
}
}
}
6. Use a node
Pick a node from environments.<environment> and check that it answers GET <node>/v1/alive with {"health_global":1}, over a valid TLS certificate. If it does not, try the next node.
Refresh the list about every hour with If-None-Match (see caching). On 304, keep the current list; otherwise, verify the new list from step 2.
Wrap these steps in a single function, such as get_verified_node(env), that returns a verified node URL. If the delivery of the list changes, only that function changes.
Libraries
Any OpenPGP implementation can verify the list, for example Bouncy Castle (Java), OpenPGP.js (JavaScript) or GnuPG. The examples in this guide use Python with gpg 2.2 or later: they show each step, not a complete program.
Common problems
| Symptom | Likely cause |
|---|---|
| A signature fails on a file downloaded unmodified | The bytes changed after download: an editor, JSON re-formatting, line-ending conversion or a proxy. |
| gpg reports a good signature, but the list is rejected | The signing key is not allowed, or there are fewer signatures than threshold. |
gpg prints no NEWSIG status line | gpg is older than 2.2. |
| Expiry errors on some machines only | Their clock is wrong. Use NTP. |
| Verifying in a browser | Verify on a server, then pass the verified node URLs to the frontend. |