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: 0.1.0. It passes all eleven modules of the conformance plan against a locally hosted OpenID Foundation suite, seven of them scored automatically. Nothing has been certified, nothing published, and no external party has used it. 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 |
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=[...]
)
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 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| polaris_oid4vp-0.1.0.tar.gz | 36.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| polaris_oid4vp-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 72.4 kB
Release files / polaris_oid4vp-0.1.0.tar.gz
| Download URL | polaris_oid4vp-0.1.0.tar.gz |
|---|---|
| Size | 36.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4218a207f92653bd116d1aba905373542a8c77f637f65a80f464ab2054b6d5b1
|
|
BLAKE2b-256 checksum How to use checksums |
8ccf97284bb35a7990394d6613d72df996c1e854e469e5810950118795a7988c
|
| 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 15, 2026.
Transparency logRelease files / polaris_oid4vp-0.1.0-py3-none-any.whl
| Download URL | polaris_oid4vp-0.1.0-py3-none-any.whl |
|---|---|
| Size | 36.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4e385c1b530253eedcc8d74e427d1f9b0ecb04d240f58cbc0e87e1783901fff1
|
|
BLAKE2b-256 checksum How to use checksums |
49e33c9a1ac018341bb98612df1e82eca155ff00c7df3f7a2c6be173b6620938
|
| 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 15, 2026.
Transparency log