Skip to main content
Pre-release

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

polaris-oid4vp

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

Status: 1.0.0-rc.1, a release candidate. The OpenID Foundation's HOSTED conformance suite ran all eleven modules of oid4vp-1final-verifier-haip-test-plan against it across the open internet: zero failures, zero warnings, seven negative modules carrying the service's own result: PASSED, four positive modules in REVIEW, awaiting a Foundation reviewer. REVIEW is not PASSED, and nothing is yet certified. One unmodified external wallet, walt.id Wallet API v2, has presented to it and been accepted. No operator other than the author has run it and no independent security review exists, which is what separates a candidate from 1.0.0. See the scoreboard for exactly how much that is and is not.


Why this is a separate package

polaris-verify promises that it opens no socket. That promise is externally stated, it is enforced by a check in the tree, and it is worth keeping: a verifier you can run on a machine with no network is a different and better thing than one you cannot. This needs to listen, so it lives somewhere else rather than quietly making that promise false.

It also carries a hard dependency on cryptography, where polaris-verify deliberately carries none. Both facts point the same way.


Why it exists at all

Under the go-forward operating contract the unit of progress is a surviving external dependency, and the first named interoperability target is an OpenID4VP 1.0 + HAIP verifier. The OpenID Foundation's conformance suite has a test plan for exactly that role, oid4vp-1final-verifier-haip-test-plan, and in it the suite signs the credential, so Polaris's own ML-DSA-65 issuance is not involved. The measurement that established this, along with the plan's contents and a run that gets every automated condition green, is in lab/interop/.

Eleven modules apply to SD-JWT VC. Seven are negative: the wallet sends a presentation broken in one specific way and the verifier has to refuse it. Those seven are scored automatically on a 4xx, with no human in the loop, which makes them the closest thing to an external adversary this project has been offered.

What is here

polaris_oid4vp/sdjwt.py verifies an SD-JWT VC presentation and returns a verdict. It touches no socket and reads no configuration: it is given the presentation, the nonce and audience the request asked for, and the keys the issuer is trusted under. Each of the seven conformance refusals has its own reason code:

Conformance module Refusal code
invalid-credential-signature issuer_signature
invalid-sd-hash sd_hash
invalid-kb-jwt-signature kb_signature
invalid-kb-jwt-nonce nonce
invalid-kb-jwt-aud audience
kb-jwt-iat-in-past kb_freshness
kb-jwt-iat-in-future kb_freshness

The conformance suite's seven are not all of them, and the rest matter as much to an integrator. These were added on 2026-09-17 after an adversarial review found fifteen defects in 1.0.0rc1, every one of which this verifier had accepted:

Refusal code What it catches
credential_validity The credential's own exp has passed or its nbf has not arrived, or either is a value whose meaning cannot be evaluated. Neither claim was read at all before: a credential its issuer stamped as expired ten years ago verified. For an offline SD-JWT VC these are the only expiry there is.
vct The credential is not the type the query asked for. Pass expected_vct; Verifier passes its own vct_values automatically. Without it a verifier asking for a personal identification credential accepts any credential the same issuer signs.
disclosure Also now: a disclosure named iss, nbf, exp, cnf, vct, status or iat, which the specification requires to be in the signed credential and never disclosed; a disclosure colliding with a claim already present; and a credential nesting disclosures past the resolver's depth cap.
issuer_key Also now: an x5c leaf outside its validity window, or one whose KeyUsage or ExtendedKeyUsage says it is not for signing. A trust anchor used for anything else no longer makes every certificate it ever issued an identity issuer.
malformed Also now: a presentation over 256 KiB, a JSON document over 64 KiB or nesting past 64 levels, and the non-JSON literals NaN and Infinity, which Python's parser accepts and which defeated the kb_freshness window entirely.

Recursive disclosures (SD-JWT 4.2.4.1), which the European digital identity wallet's personal identification credential uses for address, are accepted as of the same date. They were refused before as never committed to.

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, not what it claims
    expected_audience=client_id,
    trust_anchors=[anchor],           # or issuer_jwks=[...]
    expected_vct="urn:eudi:pid:1",    # the type you asked for; omit to not check
)
verdict.authentic, verdict.code, verdict.reason, verdict.claims

expected_nonce and expected_audience are required and are refused if empty. A verifier that reads them out of the presentation it is checking has made two of those seven tests pass by not performing them, and has made every presentation replayable.

The reason codes above are for the OPERATOR. They never go on the wire. Every refused presentation gets one constant body, whatever was wrong with it:

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

A response that named the failing check would be a per-check oracle: probe once, be told which check to work on next, and receive the verification order for free. An earlier version did exactly that, justified as debuggability for the wallet, and three of its messages leaked more than the code, including this verifier's own client_id echoed back and the replay window stated in seconds. The cause now reaches the operator through the returned Verdict and the server log, where the person running the verifier can read it and a stranger cannot.

Timing is still an oracle and this does not close it. A refusal that returns before the signature check is faster than one that returns after it. Nothing here measures that, and it is recorded as open rather than implied to be shut.

polaris_oid4vp/jwe.py opens the response. HAIP pins direct_post.jwt, so the vp_token arrives as a JWE encrypted to a key the verifier published in client_metadata.jwks, and it has to be decrypted before there is anything to check. ECDH-ES direct key agreement over P-256 with A128GCM or A256GCM, and nothing else: alg and enc arrive in an attacker-controlled header, so a short list and a refusal for everything outside it is the useful thing for a verifier to have.

polaris_oid4vp/verifier.py is the listening half, and the answer IS the result: seven of the eleven modules pass the moment the verifier responds 4xx, so every refusal the two files above can produce has to reach the wire as one. A verifier that swallows a forged presentation and returns 200 fails seven tests at once in a way that looks like nothing happening. serve.py is the two-endpoint HTTPS surface, standard library only, so a relying party installs this and no web framework.

A fresh response-encryption key per request, because the suite checks the key is not reused and is right to: one long-lived key makes every presentation ever sent to this verifier readable by whoever later obtains it.

Running one

Installing this used to leave you with a library and no way to start a verifier. Two commands now:

polaris-oid4vp keygen --out ./pki --host verifier.example
polaris-oid4vp serve  --pki ./pki --port 9443 --issuer-jwks issuers.json

keygen is the half that is not obvious. The profile pins client_id_prefix=x509_hash, and the conformance suite refuses two certificate shapes that look perfectly reasonable:

  • a self-signed leaf: "Leaf certificate in x5c chain must not be self-signed"
  • the registered trust anchor inside the chain: "Trust anchor certificate must not be included in x5c chain"

Each cost a failed conformance run to discover. keygen produces a CA, a leaf it signs, and a self-signed certificate for the listener, and prints the client_id plus the path to the anchor a counterparty registers. test_cli.py asserts those shapes rather than asserting five files appeared, the conformance drill builds its own certificates through this same command so the two cannot drift, and the product boundary drill checks the installed command from a throwaway environment.

Serving with no --issuer-jwks refuses every presentation with issuer_key. That is the correct answer for a verifier that trusts nobody, and it is a trap, so the command says so on stderr rather than letting it read as a verifier that works.

The keys it makes are for testing. A deployment's request-signing certificate comes from whatever authority its ecosystem trusts.

The 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
happy-flow                    REVIEW (screenshot)
minimal-cnf-jwk               REVIEW (screenshot)
request-uri-method-post       REVIEW (screenshot)
request-uri-fetched-twice     REVIEW (screenshot)
invalid-kb-jwt-signature      PASS
invalid-credential-signature  PASS
invalid-sd-hash               PASS
invalid-kb-jwt-nonce          PASS
invalid-kb-jwt-aud            PASS
kb-jwt-iat-in-past            PASS
kb-jwt-iat-in-future          PASS

The four REVIEW modules have zero failures and zero warnings; they end in REVIEW because the suite wants a screenshot of a verifier displaying a successful verification, which the drill cannot produce and will not fake.

The drill carries TWO negative controls, one per direction, and is VOID without either. A verifier patched to answer 400 to everything must break the four positive modules. A verifier patched to answer 200 to everything must break all seven negative ones, and that is the control that matters: those seven are the automatically scored half, so they are the claim.

The first version of the drill had only the 400 control and reported a module clean whenever its log held no FAILURE entry. Both were wrong in the same way. A verifier that accepts a forged presentation records no FAILURE either; the suite draws the line in its terminal entry, FINISHED for the automatic pass against REVIEW for "waiting for a human to explain this". Under the corrected criterion request-uri-method-post turned out to be SKIPPING itself, because it will not run unless the request carries request_uri_method=post. A skipped module has exactly as many failures as a passing one.

Tests

cd packages/polaris-oid4vp && python3 -m unittest test_sdjwt test_jwe test_verifier \
    test_serve test_cli test_conformance_capture

137 tests in six files at 99% line coverage, and they are not equal in weight. Coverage is the weakest of the three instruments here: it says a line ran.

And coverage is not the test that matters. scripts/polaris-oid4vp-mutation-drill.py takes each of the 51 refusals in turn, makes it ACCEPT what it refuses, and runs the whole suite: a refusal the suite still passes with is one no test asserts on. 48 are caught, and the 3 that survive are the cryptography is not installed guards, which a suite that runs cannot reach without removing the thing it needs in order to run. It found one real gap the 99% figure had hidden, described below.

test_sdjwt (23) and test_jwe (22) are this package agreeing with itself: the material is built here and checked here. Each carries a positive control, because a verifier that refuses everything passes every refusal test ever written, and both directions were checked by patching the verifier rather than assumed. A verifier that always accepts fails 19 of the 23; one that always refuses fails 4.

test_conformance_capture (9) is the one that is evidence of something. It holds a real direct_post.jwt response produced by the OpenID Foundation conformance suite's wallet, committed as a fixture so it runs with no Docker and no network. The JWE was built by Nimbus JOSE in Java and the SD-JWT by the suite's own code: if this package's ECDH-ES, its Concat KDF, its AAD binding or its disclosure digests were subtly wrong, it would not open. It opens, and the presentation inside verifies, disclosing exactly the two claims the DCQL query asked for out of the eleven the credential commits to.

The same fixture proves the refusals: a year later it is stale, under another nonce or another audience it is not ours, with given_name rewritten from Jean to Jeanne the digest no longer matches what the issuer signed.

test_verifier (31) drives the whole exchange in process, with a wallet that READS the request object rather than being told what is in it: if the request object were malformed, that wallet could not answer it. Seven of its tests assert each conformance refusal arrives as an HTTP 400 rather than merely being noticed, because a 400 is the whole of what those modules measure.

test_serve (18) asserts the decision becomes an HTTP response: status codes, content types, routing, form parsing, TLS. It exists because serve.py was at 0% coverage and nothing in CI touched the file that turns a verdict into a status code.

None of that is a conformance result. The suite ran locally and nothing was scored or published.

What is not zero, as of 2026-09-15: an unmodified walt.id Wallet API v2 (waltid/wallet-api2:1.0.0) presented an SD-JWT VC to this verifier over OpenID4VP 1.0 and it was accepted, against two negative controls. That exchange also produced the first defect here found from outside: keygen was emitting a request-signing leaf with no digitalSignature KeyUsage, which walt.id refused and the local conformance suite had not. The row, the controls and what it does not establish are in the scoreboard.

Metadata

Release files for polaris-oid4vp 1.0.0rc3

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.0rc3
File Size Uploaded
polaris_oid4vp-1.0.0rc3.tar.gz 45.9 kB Details

Built distribution (wheel)

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

Total release size: 91.3 kB

Release files / polaris_oid4vp-1.0.0rc3.tar.gz

Download URL polaris_oid4vp-1.0.0rc3.tar.gz
Size 45.9 kB
Tags Source
SHA-256 checksum
How to use checksums
8834e0da4f38b2801e28bf49022231ed5b1df9427e59bb77e0f11fa8b5781e01
BLAKE2b-256 checksum
How to use checksums
f3e22bb909d1add1b297677aa9283dbcf7f14f0d7fa97110f91ef1aac13c0b02
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 Sep 18, 2026.

Transparency log

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

Download URL polaris_oid4vp-1.0.0rc3-py3-none-any.whl
Size 45.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b00c39cef7990e3d73f208e245b76d951d00d1b076bf136f58f5215feb74f3d4
BLAKE2b-256 checksum
How to use checksums
2762f05151b232793353a1f58ba6c01d353799e2712e008c8010e0e0cef6a011
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 Sep 18, 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