Releases: luisgf/openvc
Release list
v1.25.0
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 dataclassesCredentialOfferandCredentialIssuerMetadata.
Fail-closed per ADR-0007 D7 (parsers in, builders out):credential_issuer
must be an absolute https URL (it feeds the key proof'saudcomparison),
credential_configuration_idsa non-empty array of distinct non-empty
strings, every endpoint URL https and absolute when present, and
batch_credential_issuance.batch_sizean integer ≥ 2. Unknowngrants
members and every extension point are preserved verbatim (typed fields and
raw), never silently dropped; nothing is ever fetched — a by-reference
credential_offer_uristays the caller's injectedFetch. New typed
errorsCredentialOfferMalformedandIssuerMetadataMalformed, plus the
wire constantsGRANT_AUTHORIZATION_CODEandGRANT_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 (whichurlparse
silently strips), userinfo, query/fragment and unparseable bracketed
literals or ports are all rejected with the typed errors, so the stored
credential_issueris byte-for-byte the identifier the key proof'saud
will be compared against and no rawValueErrorescapes the module's
taxonomy over attacker-controlled input.
v1.24.0
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_attestation → UnverifiedKeyAttestation; 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
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
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):
check_noncemust be atomic — a RedisSET … NX, a SQLDELETE … 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.TtlCacheis 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.- Codes and identifiers are yours to mint — openvc generates no pre-authorized code,
transaction_idornotification_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
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 anchorsverify_rp_registration_certificate
consumes). One interface, two encodings:openvc.trustlistgrows
parse_lote/consume_lote/walk_loteplus the
EU_WRPAC_PROVIDERS_PROFILE/EU_WRPRC_PROVIDERS_PROFILEconformance
gates (LoteProfile,LoteType,LoteServiceType,
TrustListProfileError), all distilling into the sameTrustAnchorSet
aswalk_lotl—.certificatesfeeds 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-listedcrit, the signer fromx5cauthenticated against
caller-pinned certificates (byte-for-byte or by path validation), plus
clause 6.8's DN binding (signing-certificateorganizationName↔
SchemeOperatorName,countryName↔SchemeTerritory). Parsing is strict
and fail-closed on every field that feeds a trust decision: unknown
structural members reject (the official schema is
additionalProperties: falsethroughout), date-times must be the UTCZ
form, an unrecognised critical extension rejects the list, a malformed
certificate blob is skipped rather than trusted, and a closed list
(NextUpdatenull) contributes zero anchors. The EU profiles enforce
Tables F.1–G.3 — registered URIs (including the spec's literal
WRPRCrovidersListStatusDetn typo, accepted alongside the corrected
spelling), territoryEU, the exclusive service-type pairs,
ServiceStatus/StatusStartingTime/HistoricalInformationPeriodabsent,
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_lotenow 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 whoseLoTETypematches the profile (and
consumes the pointed list under the same profile), fails closed instead of
raising an uncaughtValueErroron a far-futureListIssueDateTime,
rejects a present-but-emptyServiceStatusunder the profiles
(presence is the violation), and pins date-times to the exact
YYYY-MM-DDThh:mm:ssZform clause 6.1.3 mandates.
v1.21.0
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
moduleopenvc.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 thex5cprimitives 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_certificatebinds the WRPRC'ssubto 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), andcheck_request_within_registration
requires every DCQL credential query to match a registeredformatwhosemeta
covers the requested one, with every requested claimpathinside 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-listedalg, thex5cchain,
acritthat 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[]);expis optional (Table 10), so the 12-month ceiling
(GEN-5.2.4-08) binds only when it is present andrequire_expiry=Trueis 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) vsintermediary.name(Annex C), andintermediary.subvsact.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.pypins 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 tookclaimbeforeclaimson both sides, which is
correct for a registration (the spec's spelling) but on a request let a relying
party hide a narrow decoy inclaimand the real, broader ask inclaims— openvc
authorized the decoy while the wallet, following DCQL, answersclaims, 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 bareOverflowErrorescaping theOpenvcErrorfamily on a bignum
iat/exp/nbf(math.isfinitecasts to float, andjson.loadsyields 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-objectmetabeing
coerced to{}, which turned a malformed constraint into no constraint and
widened the entry to every credential of that format;metamatching conflating
Truewith1; and an entitlement floor that accepted any non-empty string while
ENTITLEMENT_URI_PREFIXsat 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
Part of the Q3–Q4 2026 — eIDAS deadline & ecosystem refresh milestone.
Security
- Unknown JWS
critextensions 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 publicverify_status_list_tokenentry point and the error-precedence ordering included. An adversarial review (parser tricks incl. duplicate / unicode-escapedcritkeys, lane completeness across every public entry point, precedence, hostile shapes,verify_manyisolation, 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 indocs/audit/assurance.md§5: thePyJWK/PyJWKClient/ HMAC-confusion classes are structurally unreachable (allow-list before crypto, noPyJWK(Client), SSRF-guarded JWKS fetch); thecrit(CVE-2026-32597) andb64=false(CVE-2026-48525) classes were reachable pre-2.13 on the VC-JWT lane and are closed by the floor plus the openvc-sidecritrejection above.cryptography49.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
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.mdnow 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 stayspyld>=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-bytevc-di-eddsagolden and theecdsa-sdintermediates included — passes identically on 2.0.4 and 3.1.0, so the permissive floor is deliberately kept and CI now runs apyld==2.0.4floor leg alongside the latest-resolving matrix. The bundled-context loader stays openvc's own rather than pyld 3'sFrozenDocumentLoader/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
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 envelopedURI=""reference resolving to the trust-list root (plus optionalSignedProperties/ co-signedds:KeyInfo). Anchoring onURI=""— 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-Idrelocation attack), now pinned by a regression test. - Bitstring Status List
encodedListis multibase-conformant on both sides (#115). The W3C v1.0 REC mandates a multibase (u-prefixed)encodedList, but the codec used bare base64url:decode_bitstringcould not consume a spec-conformant (or any real third-party) list, andencode_bitstringissued non-conformant ones.decode_bitstringnow tolerates theuprefix (legacy prefix-less lists still decode) andencode_bitstringemits 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 BitstringencodedListdecode 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
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_sddigest, a VC-JWT
header/payload, or an enveloped VC'sdata:payload — madejson.loads(or the
SD-JWT_unpackrecursion) raiseRecursionError(aRuntimeError, not an
OpenvcError). On the untrusted peek/unwrap path that escapedverify_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_unpacknow caps recursion at depth 100 (parity withcbor=64 /
_jcs=100), the did:webvh genesis SCID walk (_deep_replace_scid) is depth-bounded
too, and every attacker-facingjson.loads— SD-JWT, VC-JWT peek/verify,_jws,
the enveloped unwrap,jwe, and thedid:jwk/did:webvh/ fetch / status-resolver
paths — now mapsRecursionErrorto a typed error, so hostile input fails closed and
verify_manyisolates it across all formats. Resolves the R1 residual risk from
the audit pack. (#117)