Skip to content

security(ebsi): TIR roles are an exact allow-list; Revoked was trusted - #371

Merged
luisgf merged 2 commits into
mainfrom
security/ebsi-issuer-type-roles
Oct 30, 2026
Merged

luisgf merged 2 commits into
mainfrom
security/ebsi-issuer-type-roles

Conversation

@luisgf

@luisgf luisgf commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Closes #358. Milestone: Conformance & production readiness. Security fix; the CHANGELOG heading is now [1.38.0] — unreleased (one release for the trust-policy wave, maintainer's decision of 2026-10-30).

Step 0 — the vocabulary, from the two primary sources (read 2026-10-30)

  • Legacy TIR API v5: https://api-pilot.ebsi.eu/trusted-issuers-registry/v5/openapi.json (info.version 5.0.0), components.schemas.attribute.properties.issuerType.description: "RootTAO, TAO, TI, Revoked or Undefined". Its JSON-RPC example writes the type as an integer (3); the read API renders a name.
  • EBSI Core API v1: GET /v1/openapi.json on a node with Accept: application/openapi+json (measured on api.ebsi.iti.gr; info.version 1.0.0, openapi 3.2.0): issuerType is {"type": "string", "enum": ["undefined", "RootTAO", "TAO", "TI", "Revoked"], "description": "Issuer type. undefined (lowercase) is returned for type 0."}.
  • Recorded fixtures (v5 pilot tir_v5_revisions.json; Core v1 preprod GRNET / INCM and prod ITI): every attribute wrapper carries RootTAO, nothing else. Pinned as a control.
  • The EBSI hub documents the CLI input as ti / tao / roottao / revoked (lowercase) and the trust-model pages write RootTAO; the wire vocabulary is the OpenAPI one above. The 352-walk-shape-binding/review/ notes folder cited by the issue is not on this machine; the repro was rebuilt from the issue text.

Measurement on main at b0feaeb (CPython 3.14.10)

Real P-256 signatures through evaluate_issuer_trust (single) and verify_trust_chain (chain), LEAF accredited by ROOT, the anchor; the leaf attribute's issuerType varied (script: the harness of tests/test_ebsi_issuer_type_roles.py):

leaf issuerType          v4 single        v4 chain       v5 single        v5 chain  core-v1 single   core-v1 chain
<absent>                   TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
''                         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'undefined'                TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'Undefined'                TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'Revoked'                  TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'revoked'                  refused         refused         refused         refused         refused         refused
'RootTAO '                 TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'ti'                       TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'Banana'                   TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED
'TI' / 'TAO' / 'RootTAO'   TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED         TRUSTED

wildcard accreditation (no accreditedFor) with role 'Revoked' / 'undefined': TRUSTED on every lane, both policies
two hops, MID's own role 'TI' / 'Revoked' / 'undefined' / '': the chain CLOSED on every lane

So the only refused spelling was the one no registry sends. A revoked accreditation was a wrong-accept on real registry vocabulary, every lane, both trust modes, and a TI (or a revoked / unset accreditor) could accredit. The issue's severity note of 2026-10-21 was right on both counts.

What changed

  • models.py: TRUST_ROLES = {RootTAO, TAO, TI} (unchanged), new DELEGATING_ROLES = {RootTAO, TAO}, REVOKED_ROLE = "Revoked" (the wire spelling) and REVOKED_ROLES = {Revoked, revoked}; Accreditation.has_trust_role, Accreditation.can_accredit; is_revoked reads both spellings. The comment cites both OpenAPI documents.
  • trust._pick_accreditation: an accreditation is eligible only with has_trust_role, and above the leaf only with can_accredit; the role rule runs before the wildcard / type rule, so an unrestricted accreditation with a bad role authorises nothing. New trust.ineligible_roles_note(record, leaf=): a bounded clause (safe_repr, role cut at 48 chars, at most three distinct) appended to the NoTrustedAnchor message, whose leading words are unchanged.
  • verify.evaluate_issuer_trust: the same eligibility rule; the IssuerNotTrusted message gains the same clause.
  • Adapters unchanged: they copy the string the wire sends (policy-level option of the issue, as planned).
  • Not done, on purpose: the anchor's own record is not read. The caller pins the anchor DID and the walk stops at it; reading it would cost one request per walk and guard a misconfiguration (a TI pinned as anchor), not an attack. Said in the module docstring and the CHANGELOG.

Tests

tests/test_ebsi_issuer_type_roles.py (127 cases, every lane v4 / v5 / Core v1, both policies): the headline Revoked (through verify_ebsi_badge in both trust modes and require_trust=False); 17 spellings outside the vocabulary (absent, empty, undefined, Undefined, revoked, REVOKED, trailing / leading space, lowercase, zero-width space, TAO,TI, 3, Banana); the role rule before the type rule (wildcard + bad role); the two-hop delegation rule (TI, Revoked, undefined, empty, absent accreditor refused; TAO / RootTAO accepted); a record mixing a revoked and a live attribute picks the live one; bounded messages (a 70 000-character role, both entry points) that name the role and the rule; the model predicates; the recorded registries carry RootTAO only. Every negative is TRUSTED on main (table above) and refused here: removing either new check fails named tests (mutation check).

Two existing tests pinned the old behaviour and were adjusted, keeping their intent: test_ebsi_core_v1.py::test_an_attribute_object_with_no_usable_body_is_still_a_wildcard_by_design (two shapes had no issuerType; they now carry TI, since the body is the point there) and test_trust_chain.py::test_pick_accreditation_accepts_wildcard_consistent_with_single_level (calls _pick_accreditation above the leaf; the fixture role is TAO). The two tests that spelt revoked lowercase (test_trust_chain.py, test_verify_glue.py) still refuse, now for the role rule.

Docs

CHANGELOG ### Security under [1.38.0] — unreleased (with a migration note); wiki/Trust.md (the sentence where roles are first explained, plus a bullet); docs/audit/threat-model.md row I24; docs/audit/assurance.md §4 bullet #358; module docstrings.

Review

Trust evaluation: an adversarial review runs before merge (posted below). Merge waits for the maintainer's OK, as the issue says.

Both registry APIs render the on-chain issuer type as a name and both
spell the revoked one `Revoked` (legacy TIR v5 OpenAPI 5.0.0: "RootTAO,
TAO, TI, Revoked or Undefined"; Core API v1 OpenAPI 1.0.0, served by
every node at /v1/openapi.json: ["undefined", "RootTAO", "TAO", "TI",
"Revoked"]). `Accreditation.is_revoked` compared with the lowercase
`revoked`, which neither emits, and nothing checked that the role is a
live one, so on v4, v5 and Core v1 a revoked accreditation, an unset one
(`undefined`, absent, empty) and any other string were eligible for
single-level and recursive trust, and a TI could stand above the leaf.

An accreditation is now eligible only if its issuerType is exactly
RootTAO, TAO or TI, and above the leaf only RootTAO or TAO may accredit;
the role rule runs before the type rule; REVOKED_ROLE spells Revoked and
is_revoked also reads the old lowercase; the "no valid accreditation"
messages name the roles set aside, bounded. The adapters are unchanged.

Closes #358
The adversarial review of PR #371 (no wrong-accept found) left two
LOW items: the refusal message above the leaf quoted `sorted(needed)`
raw (with a wildcard leaf accreditation that is the credential's whole
type list; 1 002 163 characters measured on main with 500 types), and
the new note de-duplicated roles with a list membership test (v4
inline attributes are uncapped). Both bounded now, with regression
tests; the three stale test artefacts it named are refreshed (the
wire spelling `Revoked` in the two older tests, the v4 shape docstring).

Refs #358
@luisgf

luisgf commented Oct 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review (before merge)

Reviewer: a subagent on the session model, instructed to break the change with runnable repros (scripts kept locally under the session's scratchpad, review-358/). Result: no wrong-accept, no bare exception, no policy divergence. Not merge-blocking. Two LOW items fixed in the second commit, with regression tests.

What was tried and failed closed

  • Role bypass, 38 values × 3 lanes: fullwidth TI/TAO, combining marks, ZWJ/ZWNJ/BOM/NUL/tab/NBSP/soft hyphen/RTL override, Cyrillic confusables, ti/ROOTTAO/RootTao, Revoked/revoked/undefined/Undefined, empty/padded, a lone surrogate, 70 000 characters, TAO,TI, and a str subclass with lying __eq__/__hash__/__contains__ injected through fetch → all refused on single-level, chain and verify_ebsi_badge; null/number/bool/list/object → typed MalformedRegistryResponse on every lane; the exact TI control trusted.
  • Position confusion: an instrumented 3-hop LEAF←MID←MID2←ROOT gives leaf flags [True, False, False] on every lane; TI at MID or MID2 refused with the right DID in the message; a LEAF↔MID cycle is TrustChainError; a non-leaf record [Revoked-wildcard, TI-wildcard, TAO-scoped-non-superset] refused.
  • Policy divergence: 20 000 random records (12 spellings × scoped/wildcard × hasAttributes) → evaluate_issuer_trust(rec, t) is _pick_accreditation(rec, set(t), leaf=True) every time.
  • Fail-open: hostile roles × 1 MB type lists × all lanes × both trust modes × require_trust → zero non-OpenvcError exceptions; require_trust=False always trusted=False; the note never shows a token body.
  • Third decision site: none (openvc core never reads accreditations; REVOKED_ROLE is not re-exported).
  • The mutation claim, re-run against git archive main: 100 failed / 27 passed on main; the only negatives main already refused are the lowercase revoked ones, via the old is_revoked.

Fixed after the review (second commit)

  • LOW-1: the refusal message above the leaf quoted sorted(needed) raw; with a wildcard leaf accreditation that is the credential's whole type list, and 500 types of 2 000 characters made a 1 002 163-character NoTrustedAnchor (identical on main: the recursive-mode gap fix(ebsi): redact userinfo and bound the remaining echoed error text #351 missed; the TI-above-the-leaf refusals now route through it). Now bounded_text; test test_the_non_leaf_message_bounds_the_delegated_types.
  • LOW-2: the note de-duplicated roles by list membership (O(n²·L); the v4 inline lane caps no attribute count; 2.1 s measured at 20 000 × 250). Now a set beside the list; test test_many_distinct_roles_are_counted_not_listed.
  • INFO: the message bound is ~1 000 characters, not ~500, when roles are lone surrogates (safe_repr expands them); still bounded. A hand-built Accreditation with a lying str subclass would read as eligible, but _wire_str copies an exact str on every lane, so it is unreachable from a registry answer. Three stale test artefacts refreshed (the wire spelling Revoked in test_trust_chain.py / test_verify_glue.py; the v4 shape docstring).

Policy residual, documented: the anchor's own record is never read; with ROOT pinned and ROOT's record saying Revoked, the chain still closes (the caller pins the anchor).

@luisgf
luisgf merged commit 8faac8b into main Oct 30, 2026
15 checks passed
@luisgf
luisgf deleted the security/ebsi-issuer-type-roles branch October 30, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ebsi): check issuerType against the trust roles; absent is not a role

1 participant