Skip to content

Releases: luisgf/openvc

v1.25.0

Choose a tag to compare

@luisgf luisgf released this 13 Aug 20:00

openvc 1.25.0 — the OID4VCI discovery parsers (issue #142), closing the milestone's last feature item. Also aboard: eight fail-closed security hardenings from the audit loop (#152#176) and their doc fixes.

Added

  • OpenID4VCI discovery parsers: untrusted Credential Offers and Issuer
    Metadata
    (#142).
    openvc.openid4vci.parse_credential_offer (OID4VCI 1.0 §4.1.1) and
    parse_credential_issuer_metadata (§11.2.3) parse the third-party JSON a
    wallet — or an issuer checking its own deployment — receives, into the
    frozen dataclasses CredentialOffer and CredentialIssuerMetadata.
    Fail-closed per ADR-0007 D7 (parsers in, builders out): credential_issuer
    must be an absolute https URL (it feeds the key proof's aud comparison),
    credential_configuration_ids a non-empty array of distinct non-empty
    strings, every endpoint URL https and absolute when present, and
    batch_credential_issuance.batch_size an integer ≥ 2. Unknown grants
    members and every extension point are preserved verbatim (typed fields and
    raw), never silently dropped; nothing is ever fetched — a by-reference
    credential_offer_uri stays the caller's injected Fetch. New typed
    errors CredentialOfferMalformed and IssuerMetadataMalformed, plus the
    wire constants GRANT_AUTHORIZATION_CODE and GRANT_PRE_AUTHORIZED_CODE.
    pinned against the spec's own §4.1.1/§4.1.2/§11.2.3 examples and the
    recorded EU-reference-issuer artifacts from #147. URL validation is the
    adversarial-review-hardened one: control characters (which urlparse
    silently strips), userinfo, query/fragment and unparseable bracketed
    literals or ports are all rejected with the typed errors, so the stored
    credential_issuer is byte-for-byte the identifier the key proof's aud
    will be compared against and no raw ValueError escapes the module's
    taxonomy over attacker-controlled input.

v1.24.0

Choose a tag to compare

@luisgf luisgf released this 29 Jul 13:21

One additive feature on 1.23.1, and one behaviour tightening that ships with it:
OpenID4VCI key resolution now sees the proof it is resolving for, and key attestations
are parsed and bound — never trusted.

The attested-key form

ResolveProofKey saw only the kid, which is blind exactly where the EU ecosystem
lives. A wallet sends {typ, alg, kid, key_attestation} and the key that signed the
proof is inside the header — one of the attestation's attested_keys. A callback
holding only the kid cannot reach it, so a consumer had to base64url-decode the proof
header itself before calling in: openvc decoding a header once and the caller decoding it
again, two implementations of "what is a header", one of them free to drift.

from openvc.openid4vci import peek_key_attestation, verify_credential_request_proofs

proofs = verify_credential_request_proofs(
    body, credential_issuer=CREDENTIAL_ISSUER, check_nonce=store.consume,
    # this ecosystem reads `kid` as a position in attested_keys; yours may differ
    resolve_proof_key_in_context=lambda ctx: ctx.key_attestation.attested_keys[int(ctx.kid)])

attested = peek_key_attestation(proofs[0].key_attestation)   # UNVERIFIED
if "iso_18045_high" not in attested.key_storage:             # your policy, your call
    raise PermissionError("this credential needs high-assurance key storage")

New public surface: resolve_proof_key_in_context= taking the frozen ProofKeyContext
(kid, alg, a read-only header, the parsed key_attestation, credential_issuer,
index); peek_key_attestationUnverifiedKeyAttestation; peek_proof_header;
KEY_ATTESTATION_TYP; MAX_KEY_ATTESTATION_BYTES.

ResolveProofKey is unchanged and not deprecated. Existing resolvers keep working
untouched — the new resolver is a separate keyword taking a context object, which grows
without ever breaking them again. Pass one or the other, never both: a precedence between
two key resolvers is the same silent-preference defect the exactly-one-key-parameter rule
exists to prevent.

Which key a kid names stays yours. OpenID4VCI fixes no rule for it: the spec's own
example uses an index, wallets also use each JWK's kid member or an RFC 7638 thumbprint.
openvc hands over the material rather than guessing between three conventions and picking
the wrong key out of a list the attacker supplied.

Behaviour change: the App. D binding

A proof carrying key_attestation must now be signed by one of its attested_keys
(App. D's MUST, compared by RFC 7638 thumbprint, on every key source), and a malformed
attestation rejects the proof before any crypto rather than being read at the end.
Previously the header was captured verbatim and never looked at.

This check stops no attacker. Whoever forges a proof also chooses its
key_attestation, whose signature nothing here verifies, so they attest their own key.
It catches an honest wallet — or your own resolver — handing over a key the wallet never
claimed, which would otherwise mint a credential bound to the wrong key and verify
cleanly. It can only reject, never accept, which is the only reason an unverified blob may
drive it at all. Believing an attestation — its signature, its wallet-provider anchor, its
assurance levels — remains yours (ADR-0007 D9, amended by a 1.24.0 addendum).

Upgrading: if your wallets send key_attestation today, check that the key your
deployment resolves is actually listed in it. Previously accepted, now rejected: an
attestation that is not a parseable JWS or whose attested_keys is missing, empty, or not
an array of JWK objects; and x5c / kid deployments whose attestation does not carry
the key the chain or the registry produced. One case is worth knowing about because it
will not show up in testing: jwk_thumbprint digests the coordinate strings as given, so
a JWK with non-fixed-width x/y — non-conformant per RFC 7518 §6.2.1.2, roughly 1 in
256 per coordinate — thumbprints differently from the same key encoded correctly. The
rejection message names that cause.

No key proof that used to be rejected is now accepted.

Also

  • Vectors for App. D: the spec's key attestation and the proof that indexes it. Both are
    printed decoded in the spec and the proof's attestation is a placeholder, so what is
    third-party is the shape; the fixture's provenance says exactly that. Still missing, and
    recorded as such: an attestation from a real wallet provider.
  • examples/13_oid4vci_key_attestation.py, the whole flow end to end, executed by CI.
  • Guide, ADR-0007 addendum, threat-model I19 and Security-Model updated — each worded to
    what the binding actually buys.

Full changelog: https://github.com/luisgf/openvc/blob/main/CHANGELOG.md

v1.23.1

Choose a tag to compare

@luisgf luisgf released this 27 Jul 09:58

A patch on 1.23.0: one fix, and the third-party vectors that found it.

The fix — di_vp is unsupported, not malformed (#147)

OpenID4VCI 1.0 §8.2 lets each proof type define its own element type: a jwt proof
is a string, a di_vp proof is a JSON object (App. F.2). openvc's shape check demanded
strings for every type and ran before the proof type was ever consulted, so a
spec-valid di_vp Credential Request came back as CredentialRequestMalformed
contradicting the module's own documented contract, and telling a Credential Endpoint
the wallet had sent garbage when it had sent a proof of a type openvc declines. It is
now UnsupportedProofType.

No security change: both paths fail closed, and no request that was rejected before
is accepted now. What changes is the signal an endpoint maps to invalid_proof — and
whether an integrator can tell "unsupported" from "broken" in their telemetry.

The vectors that found it (#147, ADR-0007 D10)

The key-proof verifier shipped in 1.23.0 pinned entirely by proofs this repo minted
itself — openvc agreeing with openvc. tests/fixtures/openid4vci/ now adds material
written by others, and the bug above surfaced the moment the first one was added.

The spec's own examples. App. F.1's jwt proof turns out to be a complete, genuinely
signed ES256 token rather than an illustration. It verifies end-to-end with the clock
frozen to its own iat, so one third-party artifact holds the typ pin, the algorithm
allow-list, the key↔alg binding, the signing-input assembly and the aud/iat/nonce
checks. Two negatives ride along — the same bytes an hour later are stale, and under a
different Credential Issuer the aud binding rejects them — in both cases without
burning the nonce
. Plus the three §8.2 Credential Request shapes.

Recorded from the EU reference issuer. Issuer Metadata and two Credential Offers
captured from https://issuer.eudiw.dev (eudi-srv-web-issuing-eudiw-py) on 2026-07-27,
the offers in the deep-link form a wallet receives rather than pre-decoded. 91 KB of
metadata: 27 credential configurations across mdoc and SD-JWT VC,
batch_credential_issuance.batch_size 100, a nonce_endpoint, request and response
encryption blocks, per-config proof_types_supported carrying key_attestations_required.
None of it is what we would have invented, which is the point.

Provenance is enforced, not described: a test re-checks every recorded artifact
against the sha256 its README documents.

Still self-made, and said so. No key proof here came from a shipping wallet — that
capture needs a live Credential Endpoint openvc does not ship (ADR-0007 D1), so
#147 stays open and
tests/fixtures/openid4vci/README.md states the gap instead of papering over it.

Documentation

The 1.23.0 release notes and changelog now name the full public surface of
openvc.openid4vci — all fourteen symbols, not five — and carry the two obligations
ADR-0007 places on the consumer, because a release note is where an integrator actually
reads them: check_nonce must be atomic (a SET … NX, a DELETE … RETURNING, never
a read-then-write, and explicitly not openvc.cache.TtlCache), and codes and
identifiers are yours to mint
(#146).

The release gate also caught two stale statements: threat-model I17/I18 cited
openid4vci.py lines the fix shifted by eight, and docs/audit/assurance.md had no
OpenID4VCI row at all — the verifier shipped in 1.23.0 without one. Both fixed.

Verified

flake8 · mypy (61 source files) · 1565 tests passing, 25 skipped ·
python -m build + twine check on sdist and wheel · gitlint · the CI matrix
(3.10–3.14 plus 3.15 pre-release, and the pyld 2.0.4 leg).

No new runtime dependency — the core is still cryptography + pyjwt.

Full detail: CHANGELOG ·
guide: Issuing with OpenID4VCI

v1.23.0

Choose a tag to compare

@luisgf luisgf released this 24 Jul 21:43

Two additive features, and the scope decision behind them.

OpenID4VCI 1.0 — wallet key-proof verification (#141)

A new openvc.openid4vci verifies the openid4vci-proof+jwt a wallet sends to your
Credential Endpoint and returns the public key it demonstrated possession of — the
value SdJwtVcProofSuite.issue(holder_jwk=…) binds a credential to. The last mile
already existed; this supplies the first.

Checks run in a fixed order, structure and allow-lists before any crypto, and any
failure rejects the whole request — there is no partial issuance: the typ pin (so a
KB-JWT, VP-JWT or status-list token cannot be replayed as a key proof), the algorithm
allow-list, unknown crit, exactly one of the jwk/kid/x5c/trust_chain
header key parameters, the key↔alg (kty, crv) binding, the signature, aud pinned to
the Credential Issuer Identifier with a multi-valued aud rejected, and iat
freshness in both directions
. Across a batch: one shared nonce, no two proofs on the
same key.

Two of those are the ones implementations get wrong. iat future-dated — without
it a wallet signs once with iat = now + 10y and holds a proof that never goes stale
(check_jwt_temporal reads exp/nbf and never iat, so this is new logic).
Exactly one key parameter — two present lets an attacker pair a kid naming an
honest key with a jwk they control, and anything that silently prefers one accepts it.

Nonce single-use is the caller's, injected as a required callable rather than
documented in prose: a plain expected_nonce string cannot express "consume once,
atomically", and offering it would make the fail-open path the ergonomic one. It fires
exactly once per request and only after every signature has verified, so an
unauthenticated attacker cannot burn nonces. Replay surfaces as a distinct
ProofReplayed so an endpoint can answer invalid_nonce and hand out a fresh one.

Stateless and transport-free: no endpoint, no Authorization Server, no state store, no
response/offer/metadata builders. The claim this supports is "OpenID4VCI 1.0
key-proof verification" — not "issuance", and not HAIP
, which additionally requires
DPoP, key attestations and client authentication, all downstream.

What you get, and what you owe

The public surface: verify_credential_request_proofs, parse_credential_request,
CredentialRequest, VerifiedProof, ConsumeNonce, ResolveProofKey,
OpenID4VCIError, CredentialRequestMalformed, UnsupportedProofType,
ProofReplayed, PROOF_TYPE_JWT, PROOF_TYP, DEFAULT_PROOF_MAX_AGE_S,
MAX_PROOF_BYTES.

Two obligations travel back the other way, both load-bearing (ADR-0007):

  1. check_nonce must be atomic — a Redis SET … NX, a SQL DELETE … RETURNING.
    Never a read-then-write: under one, two concurrent requests both observe the nonce as
    unused, and the replay window openvc cannot close on your behalf re-opens.
    openvc.cache.TtlCache is explicitly not suitable — it documents its own lack of
    single-flight, which is benign for a read cache and fatal for a single-use token.
  2. Codes and identifiers are yours to mint — openvc generates no pre-authorized code,
    transaction_id or notification_id. They are opaque values with no bytes for a
    library to get right, so it does not pretend to own them.

RFC 7638 JWK Thumbprint (#140)

openvc.keys grows jwk_thumbprint (base64url) and jwk_thumbprint_bytes (raw),
covering EC, OKP, RSA and oct. The canonical form is built from an explicit
allow-list of the required members rather than by filtering a deny-list of private
ones — so kid/use/alg and every private member are excluded by construction, a
private key hashes identically to its public half, and a member nobody anticipated can
never leak into a digest. Pinned by both published golden vectors (RFC 7638 §3.1,
RFC 8037 §2), whose examples carry alg/kid and so double as the exclusion test.

ADR-0007 — the scope decision (#139)

Records which half of OpenID4VCI openvc owns, and why the other half is not a
future release but someone else's job. The rule: attacker-controlled bytes that must
be verified or parsed fail-closed are openvc's; anything with a lifetime, a socket, or
a deployment policy is the consumer's.

The charter does not move — verifying attacker-controlled bytes and failing closed
is the same posture as the OpenID4VP verifier, one protocol over, and no out-of-scope
entry is retracted. What is corrected is an inaccurate summary: "read/verify-only" had
been untrue since 1.0, since sign, issue and the status-list builders all write.

The security property likewise generalises rather than doubles: no wrong-accept
now reads "credential or key proof", and the invariant catalog gains I17–I18.

Verified

flake8 · mypy (61 source files) · 1553 tests passing, 25 skipped ·
python -m build + twine check on sdist and wheel · gitlint · the CI matrix
(3.10–3.14 plus 3.15 pre-release, and the pyld 2.0.4 leg) · published to PyPI via
Trusted Publishing.

No new runtime dependency — the core is still cryptography + pyjwt.

Full detail: CHANGELOG ·
guide: Issuing with OpenID4VCI

v1.22.0

Choose a tag to compare

@luisgf luisgf released this 22 Jul 08:14

Added

  • ETSI TS 119 602 Lists of Trusted Entities (LoTE) — the JSON trusted-list
    lane
    (#135). TS 119 602 is
    the successor data model to the TS 119 612 XML Trusted Lists, and its EU
    profiles are the EUDI wallet anchor lists: Annex F (WRPAC providers —
    who may issue relying-party access certificates) and Annex G (WRPRC
    providers — the registrar anchors verify_rp_registration_certificate
    consumes). One interface, two encodings: openvc.trustlist grows
    parse_lote / consume_lote / walk_lote plus the
    EU_WRPAC_PROVIDERS_PROFILE / EU_WRPRC_PROVIDERS_PROFILE conformance
    gates (LoteProfile, LoteType, LoteServiceType,
    TrustListProfileError), all distilling into the same TrustAnchorSet
    as walk_lotl.certificates feeds the existing X.509 path unchanged.

    A JSON LoTE travels as a compact JAdES baseline-B JWS (clause 6.8 /
    Annex G.4), so verification runs on the library's own JOSE primitives — the
    {ES256, ES384, EdDSA, Ed25519} allow-list before any crypto, the WRPRC
    lane's allow-listed crit, the signer from x5c authenticated against
    caller-pinned certificates (byte-for-byte or by path validation), plus
    clause 6.8's DN binding (signing-certificate organizationName
    SchemeOperatorName, countryNameSchemeTerritory). Parsing is strict
    and fail-closed on every field that feeds a trust decision: unknown
    structural members reject (the official schema is
    additionalProperties: false throughout), date-times must be the UTC Z
    form, an unrecognised critical extension rejects the list, a malformed
    certificate blob is skipped rather than trusted, and a closed list
    (NextUpdate null) contributes zero anchors. The EU profiles enforce
    Tables F.1–G.3 — registered URIs (including the spec's literal
    WRPRCrovidersList StatusDetn typo, accepted alongside the corrected
    spelling), territory EU, the exclusive service-type pairs,
    ServiceStatus/StatusStartingTime/HistoricalInformationPeriod absent,
    and the ≤ 6-month update window. Self-made signed vectors pin the behaviour;
    the Commission's real EU lists become golden fixtures when published.

    The adversarial review hardened the lane before merge: a profiled
    walk_lote now defaults its selection to the profile's issuance service
    type (a provider's revocation-service certificates no longer anchor
    credential verification unless explicitly selected — the review proved a
    WRPRC signed under a revocation-service CA validated through the documented
    flow), only follows pointers whose LoTEType matches the profile (and
    consumes the pointed list under the same profile), fails closed instead of
    raising an uncaught ValueError on a far-future ListIssueDateTime,
    rejects a present-but-empty ServiceStatus under the profiles
    (presence is the violation), and pins date-times to the exact
    YYYY-MM-DDThh:mm:ssZ form clause 6.1.3 mandates.

v1.21.0

Choose a tag to compare

@luisgf luisgf released this 19 Jul 10:23

Added

  • EUDI relying-party registration certificates (WRPRC, ETSI TS 119 475 V1.2.1
    clause 5.2)
    — the other half of #67's
    WRPAC. Where the access certificate authenticates who is asking, the registration
    certificate answers "were they registered to ask for this?": it carries the
    relying party's entitlements and the credentials/attributes it may request. New
    module openvc.rp_registration, with the library's usual trusted/untrusted split —
    parse_rp_registration_certificate (UNTRUSTED, header-profile only) and
    verify_rp_registration_certificate, which anchors the signer's chain in
    caller-provided registrar roots. Both profiled forms are read: the JWT
    (rc-wrp+jwt) over the JOSE lane, the CWT (rc-wrp+cwt) over the
    dependency-free CBOR/COSE codec, sharing the {ES256, ES384, EdDSA, Ed25519}
    allow-list applied before any crypto and the x5c primitives now exported from
    openvc.x5c (load_x5c_chain, load_der_chain, leaf_public_jwk).

    Verification alone only proves a registrar signed something, so two cross-checks
    carry the authorization decision, both fail-closed:
    check_matches_access_certificate binds the WRPRC's sub to the WRPAC's
    entity_identifier (GEN-5.1.1-04 — without it an attacker pairs their own valid
    WRPAC with someone else's valid WRPRC and inherits that scope; an identifier absent
    on either side is a failure, never a match), and check_request_within_registration
    requires every DCQL credential query to match a registered format whose meta
    covers the requested one, with every requested claim path inside the registered
    paths. A registered container covers its members; a request naming no claims asks for
    everything and is refused against an enumerated registration.

    Only a verify subset of the JAdES baseline B-B profile GEN-5.2.1-04 mandates is
    implemented — the signed-header profile (typ, allow-listed alg, the x5c chain,
    a crit that fails closed on any parameter this verifier does not process) plus the
    chain validation. No signature-policy processing, timestamps, or augmentation.

    Three properties of the profile are easy to get wrong and are handled explicitly:
    one WRPRC carries exactly one intended use (clause 5.2.4 flattens TS5's nested
    intendedUse[]); exp is optional (Table 10), so the 12-month ceiling
    (GEN-5.2.4-08) binds only when it is present and require_expiry=True is opt-in; and
    the CWT form has no claim-key mapping in TS 119 475 — the envelope is fully
    specified (RFC 9052/9360) and implemented, while the claims map is read accepting both
    the RFC 8392 integer keys and text keys, which is the only reading available to an
    issuer today. That lane is provisional until a real artifact exists to pin. Two
    published spec defects are absorbed rather than inherited: intermediary.sname
    (Table 10) vs intermediary.name (Annex C), and intermediary.sub vs act.sub
    (GEN-5.2.4-09) — both spellings are read. The German BMI Architekturkonzept
    certificate (rc-rp+jwt) is a different profile and is refused by name rather than
    half-parsed under the wrong claim semantics.

    No official signed vectors exist (the deliverable ships one informative, unsigned
    Annex C example; the 2026 EAA Plugtests covered TS 119 472-1), so
    tests/test_rp_registration.py pins the Annex C payload verbatim and otherwise
    builds both forms over the library's own machinery — 106 tests, negative paths first.
    Recording a real third-party artifact stays a gated follow-up.

    An adversarial review of the verify and authorization paths found six issues, all
    fixed here with a regression test named for the attack. The one that mattered:
    the claim-path reader took claim before claims on both sides, which is
    correct for a registration (the spec's spelling) but on a request let a relying
    party hide a narrow decoy in claim and the real, broader ask in claims — openvc
    authorized the decoy while the wallet, following DCQL, answers claims, and unknown
    query members are ignored downstream so the escalating query stayed valid end to end.
    The request side now takes the union of both spellings; the registration side
    keeps precedence, since only there does a second spelling risk widening a grant.
    Also closed: a bare OverflowError escaping the OpenvcError family on a bignum
    iat/exp/nbf (math.isfinite casts to float, and json.loads yields such ints
    from the wire); an empty registered {"path": []} acting as a blanket grant over the
    whole credential (an empty tuple is a prefix of everything); a non-object meta being
    coerced to {}, which turned a malformed constraint into no constraint and
    widened the entry to every credential of that format; meta matching conflating
    True with 1; and an entitlement floor that accepted any non-empty string while
    ENTITLEMENT_URI_PREFIX sat exported-but-unused — it now checks the clause-A.2
    namespace GEN-5.2.4-03 actually requires. The binding check, the algorithm allow-list,
    the chain handling and the CWT parser were probed and held.
    (#89)

Fixed

  • Documentation drift: the WRPAC was attributed to ETSI TS 119 475 (it is
    TS 119 411-8; 475 is the WRPRC) in the wiki module map and the API reference,
    where it was also described as carrying "registered entitlements" — the WRPRC's job,
    not the WRPAC's.

v1.20.4

Choose a tag to compare

@luisgf luisgf released this 18 Jul 20:39

Part of the Q3–Q4 2026 — eIDAS deadline & ecosystem refresh milestone.

Security

  • Unknown JWS crit extensions are now rejected on every JOSE verify lane (RFC 7515 §4.1.11). openvc processes no JWS extension header parameters, so a token marking any as critical must fail closed — but the hand-rolled JWS lanes (the SD-JWT issuer JWT and KB-JWT, and the IETF status-list token) accepted them regardless of PyJWT version, and the VC-JWT lane inherited pre-2.13 PyJWT behaviour (CVE-2026-32597). All lanes — VC-JWT (the ML-DSA one included), SD-JWT, KB-JWT, status-list token — now reject through one shared check (reject_unknown_crit), matching the stance the COSE and JWE paths already took. Regression tests per lane, the public verify_status_list_token entry point and the error-precedence ordering included. An adversarial review (parser tricks incl. duplicate / unicode-escaped crit keys, lane completeness across every public entry point, precedence, hostile shapes, verify_many isolation, global state) found no bypass; its coverage recommendations are these tests. (#125)
  • PyJWT floor raised to >=2.13 — 2.13.0 (2026-05-21) is a security release. The advisory-by-advisory reachability audit through openvc's usage is recorded in docs/audit/assurance.md §5: the PyJWK / PyJWKClient / HMAC-confusion classes are structurally unreachable (allow-list before crypto, no PyJWK(Client), SSRF-guarded JWKS fetch); the crit (CVE-2026-32597) and b64=false (CVE-2026-48525) classes were reachable pre-2.13 on the VC-JWT lane and are closed by the floor plus the openvc-side crit rejection above. cryptography 49.0.0 compatibility confirmed. (#125)

Verification for this release: flake8 + mypy clean; pytest 1217 passed / 20 skipped (coverage gate met); adversarial review clean (no bypass; findings were coverage recommendations, all addressed); sdist + wheel built and twine check passed; full CI matrix green (py3.10–3.14 + the pyld 2.0.4 floor leg); published to PyPI via Trusted Publishing with digital attestations.

v1.20.3

Choose a tag to compare

@luisgf luisgf released this 18 Jul 09:55

Part of the Q3–Q4 2026 — eIDAS deadline & ecosystem refresh milestone — the first two slices of the 2026-07-17 standards-review wave.

Changed

  • Documentation truth pass (2026-07 roadmap refresh). docs/ROADMAP.md now points at the live milestones — Q3–Q4 2026 — eIDAS deadline & ecosystem refresh, Conformance & production readiness and Long term — PQ, BBS & 2.0 — instead of the closed short/medium pair, and the long-term summary no longer lists shipped work (ML-DSA, DID 1.1 tolerance) as future. No code change. (#128)
  • pyld 3.x verified; the [data-integrity] floor stays pyld>=2.0.4, now tested at both edges. PyLD came back to life (3.0.0 / 3.1.0, 2026-06-19, after two dormant years) with JSON-LD 1.1 conformance fixes — exactly the class of change that could silently shift RDF canonicalization and break Data Integrity signatures, and fresh installs already resolve 3.x. The full suite — the byte-for-byte vc-di-eddsa golden and the ecdsa-sd intermediates included — passes identically on 2.0.4 and 3.1.0, so the permissive floor is deliberately kept and CI now runs a pyld==2.0.4 floor leg alongside the latest-resolving matrix. The bundled-context loader stays openvc's own rather than pyld 3's FrozenDocumentLoader / BUNDLED_CONTEXTS, so the exact context bytes feeding canonicalization ship vendored in this package (openvc.proof.contexts). (#124)

Also restores the ## [1.20.2] — 2026-07-16 CHANGELOG heading accidentally dropped in #129 (1.20.2's notes had been left dangling under the unreleased block).

Verification for this release: flake8 + mypy clean; pytest 1208 passed / 20 skipped (coverage 90%); sdist + wheel built and twine check passed; full CI matrix green (py3.10–3.14 plus the new pyld 2.0.4 floor leg); published to PyPI via Trusted Publishing with digital attestations.

v1.20.2

Choose a tag to compare

@luisgf luisgf released this 16 Jul 08:13

Two fail-closed conformance fixes, each surfaced by holding the code to real third-party artifacts instead of self-recorded round-trips.

Fixed

  • XAdES trust-list verifier now accepts real EU trusted lists (#114). The v1.20.0 1-reference pin rejected every genuine XAdES-BASELINE signature — the EU LOTL and national TLs sign the enveloped document plus their own SignedProperties (two references). Coverage is now anchored on the enveloped URI="" reference resolving to the trust-list root (plus optional SignedProperties / co-signed ds:KeyInfo). Anchoring on URI="" — not on the resolved element's tag — is what defeats XML-Signature-Wrapping; an adversarial review of the initial fix caught a tag-equality gap (a by-Id relocation attack), now pinned by a regression test.
  • Bitstring Status List encodedList is multibase-conformant on both sides (#115). The W3C v1.0 REC mandates a multibase (u-prefixed) encodedList, but the codec used bare base64url: decode_bitstring could not consume a spec-conformant (or any real third-party) list, and encode_bitstring issued non-conformant ones. decode_bitstring now tolerates the u prefix (legacy prefix-less lists still decode) and encode_bitstring emits it.

Added

  • Real Commission-signed golden fixtures — the EU LOTL (seq 388) and Spanish national TL (seq 187), verified end to end through the XAdES verifier (#114).
  • Third-party interop vectors — an SD-JWT VC from RFC 9901 Appendix A.3 (verified against the published A.5 key) and a real EUDI reference PID (ES256, x5c), plus W3C Bitstring encodedList decode vectors (the REC's Example 3 + a Digital Bazaar list) (#115).

Verified: full test matrix (py3.10–3.14), flake8, mypy, and twine check all green on the tag; published to PyPI via Trusted Publishing.

PyPI: pip install openvc-core==1.20.2

v1.20.1

Choose a tag to compare

@luisgf luisgf released this 14 Jul 06:09

Added

  • External-audit pack (docs/audit/) — audit-readiness groundwork for the
    funded external review: a code-cited
    threat-model annex
    (per-suite and per-parser attack-surface tables, the fail-closed invariants
    catalog I1–I15, and a residual-risk register R1–R8), an
    assurance report
    (property-based fuzz coverage, the harden-next gap map, and the
    adversarial-review history), and a
    reviewer index
    with the suggested review scope and EU funding routes. No code change — the
    external audit itself stays gated on funding.
    (#75)

Security

  • Fail closed on hostile deeply-nested JSON across the verify pipeline. A
    deeply-nested (but valid) JSON credential — an SD-JWT header/payload/disclosure, a
    chain of SD-JWT disclosures each carrying the next _sd digest, a VC-JWT
    header/payload, or an enveloped VC's data: payload — made json.loads (or the
    SD-JWT _unpack recursion) raise RecursionError (a RuntimeError, not an
    OpenvcError). On the untrusted peek/unwrap path that escaped verify_many's
    per-credential isolation and aborted the whole batch (denial-of-service; not a
    wrong-accept) — reachable unauthenticated and, on CPython 3.10–3.13, with ~1 KB of
    input. SD-JWT _unpack now caps recursion at depth 100 (parity with cbor=64 /
    _jcs=100), the did:webvh genesis SCID walk (_deep_replace_scid) is depth-bounded
    too, and every attacker-facing json.loads — SD-JWT, VC-JWT peek/verify, _jws,
    the enveloped unwrap, jwe, and the did:jwk / did:webvh / fetch / status-resolver
    paths — now maps RecursionError to a typed error, so hostile input fails closed and
    verify_many isolates it across all formats. Resolves the R1 residual risk from
    the audit pack. (#117)