Skip to content

security(ebsi): type match on specific types; the leaf must cover every one - #373

Open
luisgf wants to merge 2 commits into
mainfrom
security/ebsi-trust-specific-type-match
Open

luisgf wants to merge 2 commits into
mainfrom
security/ebsi-trust-specific-type-match

Conversation

@luisgf

@luisgf luisgf commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Closes #367. Milestone: Conformance & production readiness. Security fix, under [1.38.0] — unreleased; third of the trust-policy trio after #371 (#358) and #372 (#368). Rule (b), the maintainer's decision of 2026-10-30: every non-generic type of the credential must be listed by the leaf accreditation.

Step 0 — the recordings

Every recorded type list and every recorded accreditedFor.types list starts with the generic base types: the four recorded RootTAO accreditations (v5 pilot, Core v1 preprod GRNET / INCM, prod ITI) are accreditedFor [VerifiableCredential, VerifiableAttestation, VerifiableAuthorisationForTrustChain] (one […, TrustedNodesList]), and their own vc.type is [VC, VA, VerifiableAccreditation, VerifiableAccreditationToAttest]. The tests that walk them ask for ["VerifiableAuthorisationForTrustChain"] or [VC, VA, VerifiableAuthorisationForTrustChain]: under rule (b) the specific set {VerifiableAuthorisationForTrustChain} is listed, so they keep verifying. VerifiableAccreditation never appears in a recorded accreditedFor, which is why it is treated as a generic base type (an accreditation listing [VC, VA, VerifiableAccreditation] was trusted for anything on main, row 7 below).

Measurement on main at 2ec271c (#358 and #368 merged, CPython 3.14.10)

Real P-256 signatures, LEAF accredited by ROOT (the anchor), through evaluate_issuer_trust (single) and verify_trust_chain (chain), identical on v4, v5 and Core v1:

one hop                                                     single    chain
control: cred [VC,VA,MyBadge] / acc [VC,VA,MyBadge]          TRUSTED   TRUSTED
HEADLINE: cred [VC,VA,MyBadge] / acc [VC,VA,OtherType]       TRUSTED   TRUSTED
cred [VC,VA] (generic only) / acc [VC,VA,OtherType]          TRUSTED   TRUSTED
cred [VC,VA,MyBadge] / acc [VC,VA] (generic only)            TRUSTED   TRUSTED
cred [VC,VA,MyBadge] / acc [VC]                              TRUSTED   TRUSTED
cred [MyBadge,Extra] / acc [MyBadge] (one of two specifics)  TRUSTED   TRUSTED
cred [VC,VA,MyBadge] / acc [VC,VA,VerifiableAccreditation]   TRUSTED   TRUSTED
control: cred [MyBadge] / acc [OtherType] (no generics)      refused   refused

two hops (cred [VC,VA,MyBadge])                                        chain
leaf acc [VC,VA,MyBadge], MID acc [VC,VA,OtherType]                    refused
HEADLINE: leaf acc [VC,VA] (generic), MID acc [VC,VA,OtherType]        TRUSTED
leaf acc [VC,VA,MyBadge], MID acc [VC,VA] (generic)                    refused

So on real-shaped data the type rule never refused anything but two lists with no type in common at all.

What changed

  • models.py: GENERIC_TYPES = {VerifiableCredential, VerifiableAttestation, VerifiableAccreditation}, specific_types(), Accreditation.specific_credential_types.
  • trust.py: accreditation_ineligibility adds "lists only generic types" after "lists no type"; _pick_accreditation applies ONE rule at every position: the accreditation's specific types must be a superset of the needed specific types (at the leaf, the credential's; above it, the delegated scope); verify_trust_chain computes the credential's specific types once, refuses a credential with none before any registry request, and sets the delegated scope to them (TrustChain.scope no longer carries the generics; the leaf-is-anchor shortcut keeps returning without a request, with the specific types as scope).
  • verify.py: evaluate_issuer_trust uses the same superset rule over specific types (so the two policies stay identical by construction); the IssuerNotTrusted message says when the credential names no specific type.
  • Not changed: schemaId / limitJurisdiction matching (out of scope); the anchor's own record is still not read.
  • Intended relaxation: above the leaf an accreditor no longer has to list the generic types (main compared whole lists there).

Tests

tests/test_ebsi_trust_specific_types.py (every lane, both policies, verify_ebsi_badge in both trust modes and require_trust=False): the headline (five accreditedFor lists that share only generics with the credential), the generic-only / typeless credential (refused before any request, with a message that says why), [MyBadge, Extra] against [MyBadge], a record picking the accreditation that covers every type, five two-hop chains that cannot close on generics, the accreditor covering the whole delegated scope, the model helpers, the message for a generic-only accreditation. The headline, generic-only and one-of-two-types negatives are TRUSTED on main (table above) and refused here; the [] credential, three two-hop cases and the ["Unknown"] unit row were refused on main already (corrected after the review). Mutation check on a scratch copy: superset → intersection in _pick_accreditation (12 tests fail), the same in evaluate_issuer_trust (7), the generic-only accreditation rule removed (3), VerifiableAccreditation not treated as generic (4).

Two existing ES256K golden tests flip on purpose (tests/test_es256k_ebsi_chain.py): they handed the recorded RootTAO's own …ToAttest accreditation to verify_ebsi_badge as the badge, and it was trusted only through the generic types (its specific type VerifiableAccreditationToAttest is not what its accreditedFor lists, VerifiableAuthorisationForTrustChain). They now pin IssuerNotTrusted / NoTrustedAnchor (and trusted=False under require_trust=False); the signature checks they also pin are unchanged. Said in the CHANGELOG migration note.

Docs

CHANGELOG ### Security under [1.38.0] — unreleased (with the migration note: [VC, VA, A, B] now needs an accreditation listing both A and B); wiki/Trust.md (a new bullet and the "delegation scoping" sentence); docs/audit/threat-model.md row I26 and the narrative that listed this as open; docs/audit/assurance.md §4 bullet #367; module docstrings.

Review

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

…ry one

Both trust policies matched an accreditation by intersecting the
credential's whole type list with accreditedFor.types, and on real
EBSI data both lists start with the generic base types
(VerifiableCredential, VerifiableAttestation), so the intersection was
never empty: an issuer accredited only for OtherType was accepted for
every credential, and a chain whose leaf accreditation listed only
generic types closed through an accreditor scoped to another type.

The generic base types are ignored on both sides and every specific
type of the credential must be listed by the leaf accreditation (rule
(b), the maintainer's decision of 2026-10-30); the delegated scope is
the credential's specific types; a credential with no specific type,
or an accreditation listing only generic types, is refused.

Closes #367
…he anchor

The adversarial review of PR #373 (no wrong-accept beyond the intended
relaxation above the leaf) found that the leaf-is-anchor shortcut in
verify_trust_chain returned before the "no specific type" refusal, so a
generic-only credential issued by a pinned anchor was trusted with an
empty scope, against what the CHANGELOG and threat-model I26 promise.
The refusal runs first now; tests pin it and the shortcut's scope (a
mutant returning the whole type list survived the suite). The
CHANGELOG, assurance and test docstrings now say exactly which
negatives main already refused, and name the intended relaxation.

Refs #367
@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 (kept locally under the session's scratchpad, review-367/; every script ran against git archive main too). Result: no wrong-accept beyond the intended relaxation above the leaf; the two policies agree exactly. Not merge-blocking. One MEDIUM fixed in the second commit.

What was tried and held

  • A differential of evaluate_issuer_trust vs _pick_accreditation(leaf=True/False) vs an oracle written from the rule text: 6 × 20 000 random records over generics, generic-name variants, kind names, roles, NUL and lone surrogates: 0 mismatches.
  • 2- and 3-hop chains on every lane: every failing hop named; scope exactly the credential's specific types; a mixed MID record skips a subset accreditation and picks the covering one; a …ToAttest in MID's record is not used above the leaf.
  • Bounds: 100 000 types, 70 000-character types, NUL, lone surrogates: typed errors, at most 453 characters, at most 58 ms per call; the worst case (200 accreditations × 100 000 types) 1.37 s, linear.
  • Docs: no remaining wording describes the intersection as current. EBSI, trust and docs tests: 5 420 passed. 10 mutants: all killed but the one below.

Fixed after the review

  • MEDIUM: the leaf-is-anchor shortcut in verify_trust_chain returned before the "no specific type" refusal, so a generic-only credential issued by a pinned anchor was trusted with an empty scope (as on main), against what the CHANGELOG and I26 promise; a mutant returning the whole type list as the shortcut's scope survived. The refusal runs first now; test_an_anchor_issued_credential_with_no_specific_type_is_refused_too and test_the_anchor_shortcut_scope_is_the_specific_types pin both (4 tests fail on the old order).
  • LOW-2: "every negative is TRUSTED on main" was overstated: the [] credential, three two-hop cases and one unit row were refused on main already. The PR text, CHANGELOG, assurance and the test docstring now say exactly which.
  • INFO: the one widening against main is intended by rule (b): above the leaf an accreditor no longer has to list the generic types. Now named in the CHANGELOG and I26.

Left for follow-ups (not regressions; identical or stricter than main)

  • LOW-1: "every specific type must be listed" only counts string entries, because verify._credential_types drops non-strings and reads an object's keys ([..., ["Extra"]], {"Extra": 1}, 7). That is fix(ebsi): type the credential type list verify.py reads for trust #359, next wave; expected_types already refuses the wrapped forms.
  • LOW-3: the generic set matches the exact spelling only, so an accreditor that signs accreditedFor with the generic IRI https://www.w3.org/2018/credentials#VerifiableCredential (or a case / whitespace variant) and an issuer that carries the same string are matched as a specific type. Needs the accreditor to sign it; with the variant on one side only the check fails closed.
  • LOW-4: the superset check runs over the union of all accreditedFor entries of one token, and schemaId is ignored: one token with entries {S1: [A]} and {S2: [B]} trusts [A, B], two tokens granting [A] and [B] refuse it. A design question (per-entry matching, schemaId), not a break of rule (b) as written.

This branch has not been deployed

No deployments
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.

security(ebsi): type scoping is void when generic types are shared by both sides

1 participant