European Digital Identity Wallet · Federation

Cross-Tenant Trust Federation

Every CodeB Sovereign Communications tenant runs its own issuer, its own verifier, and its own trust anchors. When two tenants need to accept each other's Verifiable Credentials — a hotel accepting guest ID cards issued by a resort chain's HR wallet, a municipality accepting mDLs issued by a neighbouring city, an enterprise consortium sharing a single European Digital Identity Wallet fabric — the admin at each side enrolls the other from an admin page. Trust is directional, admin-controlled, and hot-path zero-network.

Directional by design. Tenant B enrolling tenant A means "B accepts A's credentials". B still needs to be enrolled at A for the reverse to work. This mirrors the ETSI Trusted List model — each scheme operator publishes its own list; consumers pick which lists they honour.

How enrollment works

Step 1

Admin opens the trust page

Admin at tenant B navigates to /trust-tenants-admin.html, authenticated via the tenant's OpenID Connect Provider.

Step 2

Enter the foreign hostname

Admin types the foreign tenant's fully qualified domain (for example phone.aloaha.com) and clicks Add trust.

Step 3

Server fetches the DID Document

Backend fetches https://<host>/.well-known/did.json with an SSRF-hardened HTTP client, validates the certificate against the original hostname, and extracts the issuer's EC P-256 public key.

Step 4

Key is cached per-tenant

The publicKeyJwk is written to App_Data/<tenantB>/trust/trusted-tenants.json via an atomic write with automatic backup. RFC 7517 hints (use=sig, alg=ES256, key_ops=[verify]) are supplied when the remote DID Document omits them.

Step 5

Presenter shows a credential

Some time later, a wallet presents a Verifiable Credential to tenant B whose payload has iss = did:web:phone.aloaha.com. B's verifier calls TenantTrust.ResolveJwk, gets the cached key, and runs ES256 verification — pure disk read, no network hop.

Step 6

Refresh on demand

Cache freshness is reported as a pill (fresh ≤ 7 days). Verification never expires on the cached key; a Refresh button re-fetches the DID Document to pick up issuer-key rotation.

Security hardening

Adding a foreign tenant means fetching an arbitrary URL from inside the tenant server — that surface is worth taking seriously. The fetch layer:

Which keys we accept

Not every key in a remote DID Document is legitimate for signing credentials. We only accept a publicKeyJwk when:

  1. The DID Document's id matches did:web:<requested-host>. A document served from one host that claims to be another is rejected outright (W3C DID Core §7.1).
  2. The key's id appears in the DID Document's assertionMethod or authentication list.
  3. The key's controller, if present, matches the DID subject.
  4. The key type is EC on curve P-256 — the same profile our own issuer publishes.
  5. If the DID Document lists no assertionMethod/authentication keys at all, we reject the whole document (fail-closed).

Standards alignment

LayerStandardHow we match
IdentifierW3C DID Core 1.0 · did:webForeign tenants identified as did:web:<host>; keys retrieved from their /.well-known/did.json.
Public key formatRFC 7517 JWKCached with kty, crv, x, y, kid, use=sig, alg=ES256, key_ops=["verify"].
Signature algorithmRFC 7515 · ES256P-256 with SHA-256; IEEE P1363 (64-byte) wire format enforced with explicit length check.
Credential envelopeW3C VCDM 2.0 · JOSEForeign vc+jwt credentials verify through the same code path as our own issuer; the trust check is what makes federation work.
Publication analogueETSI TS 119 612 Trusted ListOur per-tenant JSON list is functionally equivalent to a Trusted List entry — roadmap: also expose it as a mini-LOTL for off-the-shelf DSS consumers.
Federation analogueOpenID Federation 1.0Roadmap: sign each cross-tenant entry as an OIDF Subordinate Statement to prove "B declared A trusted at time T".

Operator surface

EndpointMethodAuthPurpose
/trust-tenants-admin.htmlGETOIDC (admin/superuser)Admin UI: list, add, refresh, remove, live-test.
/trust-tenants.ashx?action=listGETBearer (admin/superuser)Enumerate configured trust entries.
/trust-tenants.ashx?action=addPOSTBearer (admin/superuser)Enroll a new tenant. Body: {"host":"..."}.
/trust-tenants.ashx?action=refreshPOSTBearer (admin/superuser)Re-fetch the cached DID Document.
/trust-tenants.ashx?action=removePOSTBearer (admin/superuser)Stop trusting a tenant.
/trust-tenants.ashx?action=resolve&iss=did:web:XGETPublicVerifier hook — returns the cached JWKs for X if trusted, or trusted:false. Only exposes public keys the admin already approved.
/vcdm.ashx?action=verifyPOSTPublicW3C VCDM 2.0 verifier. Foreign issuers are automatically routed through the trust list.

Storage layout

Every tenant has its own trust file. Cross-tenant isolation is enforced by the multi-tenant-by-domain rule (each tenant is a separate App_Data/<tenant>/ subtree).

App_Data/
   phone.example.com/
      trust/
         trusted-tenants.json           <- live file
         trusted-tenants.json.backup    <- pre-write copy for atomic-replace

Signed bilateral trust statements

Every tenant publishes a signed statement at /.well-known/trust-federation.jws — a compact JWS with typ = trust-federation+jwt, signed by the tenant's own issuer key (ES256), listing every peer this tenant currently trusts along with each peer's cached public keys. Third parties (auditors, other verifiers, regulators) can pull this endpoint, resolve did:web:<host> to get the signing key, and independently verify who this tenant trusts. No central anchor, no smart contract, no LOTL required.

Why this matters. Among the open-source EUDI ecosystem (EBSI, Sphereon, Procivis, Walt.id, EUDI reference verifier, MATTR), no other implementation publishes a cryptographically-auditable bilateral peer-trust list without a central anchor. EBSI needs the blockchain; OpenID Federation needs a Trust Anchor; ETSI TS 119 612 needs a Scheme Operator. CodeB Sovereign Communications ships it as a plain signed JWS you can fetch with curl.
curl https://phone.example.com/.well-known/trust-federation.jws
# returns: base64url(header).base64url(payload).base64url(signature)
# header:    {"typ":"trust-federation+jwt","alg":"ES256","kid":"did:web:phone.example.com#issuer"}
# payload:   {"iss":"did:web:phone.example.com","sub":"did:web:phone.example.com",
#             "iat":..., "exp":..., "trust_model":"codeb-directional-peer-trust/1",
#             "trusted_peers_count":N, "trusted_peers":[ {host,did,added_at,jwks[]} ]}

Roadmap