Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

polaris-oid4vp

PyPI OpenID Certified: polaris-oid4vp verifier CI GitHub stars Discord

An OpenID4VP 1.0 verifier under the High Assurance Interoperability Profile. It checks a presentation from a wallet that has never heard of Polaris: an SD-JWT VC signed with ES256, with holder key binding, delivered over direct_post.jwt.

OpenID Certified

OpenID® Certified™ by Egor Khaklin to the OpenID4VP 1.0 + HAIP 1.0 Verifier profile at version 1.0.0rc7, which passed all eleven modules of the Foundation's hosted HAIP verifier test plan (sd_jwt_vc, direct_post.jwt; listing, 2026-09-24). The certification covers that version in that role. OpenID® and OpenID® Certified™ are trademarks of the OpenID Foundation, used under its certification terms.

Status: this release, 1.0.0rc20, is the certified rc7 verifier plus the fixes and additions made since; the certification names rc7. It is re-run weekly against the outside implementations in the table below. No operator other than the author has run it and no independent security review exists. Details: the scoreboard.

Try it in ten minutes, without cloning anything: the stranger's path takes a clean machine with Docker, this package and an unmodified walt.id wallet to an accepted presentation. If it fails for you, please open an issue.

Tested against

Outside OpenID4VP implementations present to this verifier, each unmodified unless its row says otherwise: configuration only (trust anchors, TLS roots). Every walk also sends controls that must be refused, because a verifier that accepts everything prints the same success line; among them a wrong issuer key under the same kid, the answered request again, a mismatched client_id, a key the wallet does not hold and an untrusted CA. A weekly canary re-runs the walks against the newest release on PyPI.

Implementation Maintained by Language Last walk
walt.id Wallet API v2 walt.id Kotlin 1.1.1, against 1.0.0rc16 (2026-10-04)
Credo OpenWallet Foundation TypeScript 0.7.2, against 1.0.0rc14 (2026-10-03)
eudi-lib-jvm-openid4vp-kt, the EUDI Wallet's OpenID4VP library, under a short wallet built for the walk European Commission Kotlin 0.16.2, against 1.0.0rc16 (2026-10-04)
vck (vck-openid-ktor), under a short wallet built for the walk; vck also issued the credential A-SIT Plus Kotlin 8.0.0, against 1.0.0rc16 (2026-10-04)
eudi-lib-ios-openid4vp-swift, the EUDI Wallet's iOS OpenID4VP library, under a short wallet built for the walk European Commission Swift 0.43.2, against 1.0.0rc16 (2026-10-04)
irmago, the library under the Yivi wallet, under a short wallet built for the walk Privacy by Design Foundation (Yivi) Go v1.4.0, against 1.0.0rc16 (2026-10-04)
SpruceID openid4vp, its conformance adapter with the test issuer replaced SpruceID Rust e5f29b85, against 1.0.0rc16 (2026-10-04)
Procivis One Core (core-server), which also issued the credential Procivis Rust v1.87.2, against 1.0.0rc16 (2026-10-04)
ProtocolSoup, its wallet harness (listed as certified) ParleSec Go v4.0.0, against 1.0.0rc16 (2026-10-04)
The EU reference PID issuer (eudi-srv-pid-issuer), issuing to a wallet built on the EU's libraries European Commission Kotlin v0.11.1, against 1.0.0rc16 (2026-10-04)
Credo on a cheqd ledger, the issuer a did:cheqd with its status list on the ledger cheqd, OpenWallet Foundation TypeScript, Go cheqd-node 4.2.1 and Credo 0.7.2, against 1.0.0rc15 (2026-10-04)
Multipaz, under a short wallet built for the walk OpenWallet Foundation Kotlin 0.101.0, against 1.0.0rc16 (2026-10-04)
ERICA, the German EUDI Wallet programme's verifier testing tool, with its negative modes the German EUDI Wallet programme (opencode.de) TypeScript 2c27dc92, against the tree (2026-10-04)
eudi-dev, HAIP strict mode with its TLS check on dominikschlosser Go v2.5.1, against 1.0.0rc15 (2026-10-04)
OID4VCgo IDFoundry Go 0.25.0, against 1.0.0rc14 (2026-10-03)
The OpenID Foundation's conformance suite OpenID Foundation Java eleven of eleven HAIP verifier modules clean against 1.0.0rc15, both controls noticed (2026-10-04); 1.0.0rc7 certified (2026-09-24)

The author drove every walk: interoperability with those implementations, not use by their maintainers. Each walk is one script you can run against your own copy; to test your wallet, point it at polaris-oid4vp serve as below. The "Last walk" column records the walks driven by hand. The weekly canary last walked every implementation in the table, at pinned and latest builds, against 1.0.0rc16 installed from PyPI on 2026-10-06, and every walk passed (run 37460790872).

Install and run

pip install --pre polaris-oid4vp
polaris-oid4vp keygen --out ./pki --host verifier.example
polaris-oid4vp serve  --pki ./pki --port 9443 --issuer-jwks issuers.json
  • keygen makes a CA, a leaf it signs and a listener certificate, and prints the client_id and the anchor a counterparty registers. The profile rejects a self-signed leaf and a trust anchor inside the chain; keygen avoids both. The CA and the leaf carry the key identifiers RFC 5280 asks for, which some wallets find a CA by. The listener certificate is marked for server authentication, which Apple's TLS policy requires even of a certificate a client trusts by name. Its keys are for testing.
  • A wallet that fetches the request object by POST and asks for it encrypted (its wallet_metadata lists ECDH-ES in request_object_encryption_alg_values_supported, an enc this package produces, A128GCM or A256GCM, and a P-256 key in jwks) gets the signed object encrypted to that key, as the EUDI iOS wallet kit requires. A wallet that asks for nothing, or for something this cannot produce, gets the signed object.
  • --verifier-info FILE puts attestations about the verifier into the request object, such as the registration certificate a registrar issues: a JSON array as OpenID4VP 1.0 section 5.1 has it, or one object, {"format": "registration_cert", "data": "<JWT>"}, as the German EUDI Wallet guide and its testing tool read it. The German ecosystem requires one for a PID request. This package carries it as given, checking the shape section 5.1 asks for; a registration certificate labelled with its own media type is refused. --verifier-info-profile etsi-119472-2 holds it to ETSI TS 119 472-2 (below).
  • serve --issuer-trust-anchor ca.pem trusts an issuer that signs with its certificate in x5c, as HAIP issuers do (repeatable; each file may hold several PEM certificates). The leaf must chain to an anchor, directly or through the CA certificates the x5c sends after it (at most three, in order, each certifying the one before it; each a CA allowed to sign certificates, inside its validity period and its path length, with no critical extension this does not read, and none of them the anchor itself). The leaf must be inside its validity period and, when it states a key usage, include digitalSignature; one that states extended key usages must name one an issuer may hold (ISO 18013-5's document signer, as EUDI issuers use; the EUDI PID Rulebook's document signer, 1.3.130.2.0.0.1.2, as the EU reference PID issuer uses; or client auth, code signing or email protection). A leaf that states no key usage is not restricted by one. The issuer is the leaf's subject under the anchor its chain ends at. A leaf that names an issuer must name the credential's iss: a URI subjectAltName exactly, or, for a leaf naming only DNS hosts, an https URL on one. A leaf with neither (the EU reference PID issuer's has no subjectAltName) leaves iss unbound: the verdict reports it apart, not in claims.
  • --issuer-jwks trusts every key it lists for every iss, except one whose use, key_ops or alg says it is not for ES256 signatures; list one issuer's keys per verifier.
  • --public-base-url https://verifier.example sets the HTTPS origin a wallet reaches this verifier by when it runs behind a reverse proxy or a tunnel, where the public URL differs from the local --bind/--port. It is used for the request_uri and response_uri; without it they are https://<--host>:<--port>.
  • --no-local-tls serves the local listener over plain HTTP, for when that proxy or tunnel terminates TLS and provides the public HTTPS. The listener still binds --bind (localhost by default); pair it with --public-base-url, which is then the HTTPS request_uri a wallet uses.
  • --claim names a claim to ask for (repeatable; without it, given_name and family_name): a name, a dotted path of object keys, or a JSON array of keys, optionally =VALUE (JSON) for the value it must have. A path asks for one member of a nested claim and nothing around it: --claim age_equal_or_over.18=true asks an EUDI PID whether its holder is 18 or over and nothing else, where --claim age_equal_or_over would ask for every age statement it holds. A presentation that withholds a claim, or discloses it with another value or type (1 is not true), is refused (claims). Array indices are not accepted: an undisclosed element is left out of the disclosed claims, so a position counted afterwards is not the one the issuer signed. --vct (repeatable) names the credential types to accept; without it, urn:eudi:pid:1. Verifier(claims=..., vct_values=...) takes the same, a claim as a name, a list of keys, or {"path": [...], "values": [...]}.
  • serve with neither --issuer-jwks nor --issuer-trust-anchor refuses every presentation (issuer_key) and says so on stderr. An --issuer-jwks file it cannot read, or none of whose keys can verify ES256 when no anchor is given, stops serve before it listens (exit 2).
  • The CLI is a test harness. A deployment embeds Verifier (it needs your status policy; see Revocation).

Trust, in two steps. Start with pinned trust: --issuer-trust-anchor (trust_anchors= in the API) holds the issuer CA certificates a relying party received directly, and --issuer-jwks the issuer keys. That is what this package does today. The second step, reading issuers from a trusted list, is not supported yet. The order follows the FY2025 Japan-EU educational-credential interoperability demonstration, which pre-shared its root and intermediate certificates in the first round and puts trusted lists in the scope of the second (Digital Agency, scope definition document, 2026-09-18).

This is a separate package from polaris-verify because it listens on a socket and depends on cryptography; polaris-verify does neither.

Verifying a presentation

from polaris_oid4vp.sdjwt import verify_presentation

verdict = verify_presentation(
    presentation,                     # the ~-separated string the wallet sent
    expected_nonce=request_nonce,     # what YOUR request asked for
    expected_audience=client_id,
    trust_anchors=[anchor],           # or issuer_jwks=[...]
    expected_vct="urn:eudi:pid:1",    # the credential type you asked for
)
verdict.authentic, verdict.code, verdict.reason, verdict.claims

expected_nonce and expected_audience are required: reading them from the presentation would make every presentation replayable. sdjwt.py touches no socket and reads no configuration.

For an issuer that signs with its certificate in x5c, the verdict also says who signed: issuer_subject is the leaf's subject and issuer_anchor the subject of the configured anchor its chain ends at (RFC 4514 strings). iss_bound is True when the leaf gives the credential's iss as one of its names, and None when there is no iss. It is False when the leaf gives no name an iss could be, so nothing binds iss to the key: the iss is then left out of claims, reported as unbound_iss, and not given to a status resolver. All four are None for an issuer key configured with issuer_jwks, and for a refusal.

Refusal code Refuses
issuer_signature an issuer signature that does not verify
sd_hash a key-binding JWT not bound to this presentation
kb_signature a key-binding signature that does not verify
nonce, audience a key-binding JWT for another request or verifier
kb_freshness a key-binding iat outside the window, or an exp or nbf it carries that has passed or not arrived
claims (Verifier) a presentation that withholds a claim the request asked for, discloses it with a value the request does not accept, or discloses a selectively disclosable claim the request did not select (OpenID4VP 1.0 section 6.4)
revoked (Verifier) a credential whose checked status value is not VALID (0)
credential_validity a credential past its exp or before its nbf
vct a credential of a type the query did not ask for
issuer_typ an issuer JWT not typed dc+sd-jwt, a W3C VC Data Model vc+sd-jwt credential among them
disclosure a disclosure that supplies a claim SD-JWT VC keeps out of disclosures (draft 19, section 2.2.2.3: iss, nbf, exp, cnf, vct, vct#integrity, aka_vcts, status) or anything inside one, judged by where it lands, so a disclosed sub or iat, or a private claim's status member, is accepted; a disclosure named _sd, _sd_alg or ...; a colliding claim, a digest the credential commits to twice, or nesting past the depth cap
issuer_key an untrusted key; an x5c leaf, or a CA certificate it sends, outside its validity (the reason names the period); a leaf not marked for signing (the reason names the key usage, or an extended key usage for another purpose), self-signed or a CA, or one whose subjectAltName names an issuer other than the credential's iss
crit an issuer JWT, key-binding JWT or status list token whose crit names an extension; none is implemented
transaction_data_missing, transaction_data_extra, transaction_data_mismatch (transaction data) transaction_data_hashes that lack, add to or differ from the hashes of the entries the key-binding JWT must bind, in the request's order
transaction_data_alg (transaction data) a transaction_data_hashes_alg that is not a string, not allowed by every entry it binds, absent when an entry names algorithms, or not computable here
ts12_jti, ts12_response_mode, ts12_amr (a TS12 entry naming the credential) no jti or one used before; a response_mode other than the request's; amr that is not single-member objects naming at least two TS12 categories
malformed over 256 KiB, JSON over 64 KiB or 64 levels, or non-finite numbers

Recursive disclosures (SD-JWT 4.2.4.1) are supported.

Refusal codes are for the operator and never go on the wire. Every refused presentation gets one constant body, so the verifier is not a per-check oracle:

{"error": "invalid_request", "error_description": "the presentation was not accepted"}

Open: response timing still differs by which check refused; this is not measured.

The response JWE (jwe.py) accepts only ECDH-ES over P-256 with A128GCM or A256GCM, with a fresh encryption key per request. The listener (verifier.py, serve.py) is standard library only.

Transaction data

A request may carry OpenID4VP 1.0 transaction_data (section 8.4), opt-in per request: Verifier.new_request(transaction_data=[...]), or serve --transaction-data FILE --once. An entry is a base64url string, sent exactly as given, or a JSON object, encoded once. It must name the request's credential query (--credential-query-id, default pid), and a TS12 payment (urn:eudi:sca:payment:1) must satisfy TS12's own JSON Schema for it, or the request is refused before it is sent. A request without transaction_data is unchanged.

The presentation's key-binding JWT must then carry:

  • transaction_data_hashes: base64url(HASH(the ASCII bytes of each string as sent)), never of the decoded JSON (Appendix B.3.3.1), for exactly the entries it binds, in the request's order;
  • transaction_data_hashes_alg: a string naming an algorithm every entry it binds allows (sha-256 by default; sha-384 and sha-512 when an entry lists them), required when one lists any;
  • for a TS12 entry (urn:eudi:sca:...) naming its credential (TS12 section 3.6): a jti not seen before, the request's response_mode, and amr naming at least two TS12 categories.

verdict.transaction_data says what the JWT was held to: the entries, the reading, the algorithm and the jti.

Which entries each key-binding JWT binds, when a response presents several credentials, is not settled by OpenID4VP 1.0 (SIROS's question B8). Both readings are implemented and one is named: per_credential, the entries whose credential_ids name that credential (SIROS, multipaz), or all_entries, every entry (SUNET/vc). Under per_credential an entry naming two presented credentials is bound by exactly one of them, as section 5.1 has the wallet "use only one of the referenced Credentials"; twice or not at all is refused. polaris-oid4vp verdict --request R --vp-token T judges a captured response under both, and with two readings that differ decides only under --transaction-data-scope. Otherwise it accepts nothing: accepted is null and the command exits 2. An undecided reading is not an acceptance. With one credential query, as serve asks, both expect every entry. lab/interop/transaction-data holds this to SIROS's published vectors and to a two-credential vector.

Not covered: the DC API, mdoc, schemas for TS12's other three types, TS12 rules its schema does not encode, and JSON Schema format (an annotation). A Verifier remembers a jti for one day.

verifier_info under ETSI TS 119 472-2

ETSI TS 119 472-2 V1.2.1 makes verifier_info mandatory in its HAIP-common request object (OIDFVP-HAIP-COMMON-REQ-RO-01 to -16), and the German EUDI Wallet (its core library 0.30, in iOS 0.3.9 and Android 0.61.3) refuses every request from an x509_hash verifier, as this one is, that carries no registration certificate. Verifier(..., verifier_info_profile="etsi-119472-2"), or serve --verifier-info-profile etsi-119472-2, refuses to start a request whose verifier_info does not meet those requirements: a ValueError names the requirement's ID, and serve exits 2 before it listens. Under the profile:

  • verifier_info is an array of objects with exactly one registrar_dataset element, whose data holds identifier, srvDescription, registryURI, intendedUseIdentifier, purpose and policyURI, each non-empty and the two URIs absolute, and optionally credential;
  • it carries at most one registration_cert element, its data a non-empty string; neither element carries credential_ids, and a format naming a registration certificate otherwise (WRPRC, registration_certificate) is refused;
  • with a registration certificate, the request may ask for nothing the certificate does not register: the DCQL query's format and meta, and each claim path. The certificate's credentials are read as ETSI TS 119 475 writes them, with claim (a certificate saying claims is refused as not ETSI's key, never read as registering nothing; one without claim holds the format and meta only). Claim paths compare exactly, the way the Android wallet compares them: a registered ["age_equal_or_over"] does not cover ["age_equal_or_over", "18"], nor ["nationalities", null] cover ["nationalities", 0]. values are not compared. serve says at startup whether this check ran.

Shape and labels only. The profile does not validate the registration certificate's signature or chain, the registrar's data against its registry, or the access certificate that signs the request; it reads the certificate's payload, unverified, only to check this verifier's own request against it, and judges no wallet's behaviour. RO-16 has the certificate's data be "the base64url encoding of the serialized RP registration certificate", which leaves open whether a JWT goes as its compact serialization or encoded once more; both forms are read.

In every mode, profile or none, a registration certificate labelled with its own media type (rc-wrp+jwt or rc-wrp+cwt as its format, or a JWS typed rc-wrp+jwt under another format) is refused, because its format is registration_cert. Otherwise a request without the profile is unchanged, byte for byte. The cases, as JSON another verifier can read, are in testdata/verifier-info. The approach follows EUDIPLO's registration_cert configuration and ERICA's request checks.

Revocation

verdict.revocation is one of seven states:

State Meaning
no_status_claim the credential names no status list
not_evaluated it names one and no resolver was supplied
unsupported_status its status claim is in a form this verifier cannot read
checked a resolver answered; status carries the issuer's value
unreachable a resolver was asked and got no answer
no_authority no key is stated as entitled to publish this issuer's status at that URI, so no list can count as evidence
list_refused the status reference, the list or its signature failed a check, so nothing is usable as evidence; code says which
  • A checked status that is not VALID is refused: with a resolver, Verifier answers a credential whose status list value is not 0 with the same 400 as any refusal (revoked). The other states are not facts about the credential and stay in the verdict for your policy.
  • Opt-in: pass status_resolver= to Verifier(...) or verify_presentation. Without one nothing is fetched and the state is not_evaluated.
  • status.py decides a Token Status List (draft-ietf-oauth-status-list) without opening a socket; decide_by_fetching takes your fetch callable.
  • Who may publish status for whom is stated, not inferred: StatedAuthority records that a named key may publish for a named issuer at a named URI. Anything else is no_authority, and no request is sent to a URI an unvetted credential names.
  • Unbound iss: delegated status lists are not consulted; status is established only by a list signed with the credential's own key. The resolver is given issuer=None for such a credential, so a StatedAuthority entry for the name in its iss is never found.

Conformance run

git clone --depth 1 https://gitlab.com/openid/conformance-suite.git
cd conformance-suite && docker compose -f docker-compose-prebuilt.yml up -d
python3 scripts/polaris-oid4vp-conformance-drill.py

Seven negative modules PASS; four positive modules end in REVIEW (the suite wants a screenshot of a displayed result). The drill carries two negative controls: a verifier that refuses everything must fail the positive modules, and one that accepts everything must fail all seven negative ones.

The wallet canary runs the drill weekly against the newest release on PyPI, with the suite's prebuilt images at their newest (POLARIS_OID4VP_INSTALLED=1 makes it import the installed package instead of the tree). A run is not a certification.

Tests

cd packages/polaris-oid4vp && python3 -m unittest test_sdjwt test_jwe test_verifier \
    test_serve test_cli test_conformance_capture test_status
  • 603 tests in seven files. test_conformance_capture replays a real direct_post.jwt response built by the OpenID Foundation suite's wallet (Nimbus JOSE), so this package's ECDH-ES, KDF, AAD binding and digests are checked against an independent implementation.
  • scripts/polaris-oid4vp-mutation-drill.py makes each of the 176 refusals accept and requires a test to fail: 172 are caught, and the 4 survivors are declared with their reasons (measured 2026-10-11; CI runs it on every push).
  • Held-out boundary mutations (off-by-one windows, header binding, listener routing) each have a dedicated test class.

Metadata

Release files for polaris-oid4vp 1.0.0rc20

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for polaris-oid4vp 1.0.0rc20
File Size Uploaded
polaris_oid4vp-1.0.0rc20.tar.gz 111.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for polaris-oid4vp 1.0.0rc20
File Interpreter ABI Platform
polaris_oid4vp-1.0.0rc20-py3-none-any.whl Python 3 none any Details

Total release size: 218.4 kB

Release files / polaris_oid4vp-1.0.0rc20.tar.gz

Download URL polaris_oid4vp-1.0.0rc20.tar.gz
Size 111.6 kB
Tags Source
SHA-256 checksum
How to use checksums
dc542609fa7d980d923dc7c44bced1f5894737c98eba974a6711c7b43d33a033
BLAKE2b-256 checksum
How to use checksums
e8e23af8a47278f9d9de6947ab27cd513f642ebcef88631df2962ac3c43131b4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / polaris_oid4vp-1.0.0rc20-py3-none-any.whl

Download URL polaris_oid4vp-1.0.0rc20-py3-none-any.whl
Size 106.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0b8a7e811a9904f027e5250485371c037ef6fca230400c6b37219fd4814af33a
BLAKE2b-256 checksum
How to use checksums
5db4d6a844ff2c494decc1637ad61dee63db5a9052c4a783e21893d4fb1e5960
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page