Skip to content

test: [T4] execute the real-wallet interoperability validation pass (#240 procedure) #376

Description

@EsTharian

Summary

#240 shipped a documented procedure for validating QAuth's OID4VP verifier against a
real wallet — docs/wallet-interop-manual-validation.md — and has never executed it. The
document says so itself in its second paragraph (docs/wallet-interop-manual-validation.md:9-13)
and its findings table is empty (:40-42).

Consequently every conformance claim QAuth can currently make about its wallet path is
self-consistent rather than verified
: our verifier, talking to a mock we wrote, answering
requests the way we read the spec. This issue is the spike that replaces that with evidence
from a counterparty we did not author, and records the outcome — including a negative one —
in the document #240 already created.

This is a research/validation spike, not a feature. Nothing here changes shipped behaviour.
Defects the pass surfaces become their own issues; this one closes when the evidence exists.

What is true today (verified on test/240-wallet-e2e, PR #375)

  • The E2E suite's only counterparty is apps/auth-server/src/testing/mock-wallet.ts, built for
    test: [T4] E2E integration tests — reference/mock wallet (ADR-004) #240. It is deliberately constructed from @qauth-labs/core-crypto + jose with no import
    of the code under test
    (mock-wallet.ts:29-35), which is the strongest thing an in-repo
    mock can do — and still not an independent implementation. Its own docblock says as much:
    "Not a conformance target, and not a claim that any real wallet behaves this way"
    (mock-wallet.ts:49-53).
  • The suite drives the server through app.inject()
    (apps/auth-server/src/app/wallet-federation.integration.test.ts:96 onward). No socket, no
    TLS, no reverse proxy, no real browser cookie enforcement is exercised anywhere.
  • The mock already signs and key-binds with ES256 end to end (mock-wallet.ts:141), so the
    verification side is not the gap — QAuth verifies the algorithm real wallets actually use.
  • The only shipped issuer-key resolver is the statically pinned one:
    createConfiguredIssuerKeyResolver (libs/fastify/plugins/federation/src/lib/wallet-credential-verification.ts:116-124)
    createStaticIssuerKeyResolver (libs/server/federation/src/oid4vp/issuer-key-resolution.ts:152).
    The profile table declares issuerKeyResolution: ['x5c', 'issuer-metadata']
    (libs/server/federation/src/profiles/verifier-profiles.ts:41) but neither backend exists.
    This is load-bearing for the exercise: see "The issuance problem" below.
  • haip-1.0 still refuses to boot, and that refusal is tested
    (apps/auth-server/src/app/wallet-federation-haip.integration.test.ts:51-78). So the pass this
    issue asks for is a oid4vp-1.0-base pass, as the document's own checklist already scopes it.

Correction to fold into the document while you are there

docs/wallet-interop-manual-validation.md:31-34 and its HAIP heading (:166) attribute the
HAIP block to #298. That attribution is stale. #298 closed 2026-07-26 and its crypto
landed: JwsAlgorithm = 'EdDSA' | 'RS256' | 'ES256'
(libs/core/crypto/src/lib/algorithms.ts:61), plus libs/core/crypto/src/lib/jwe.ts,
encryption-keys.ts and signing.es256.test.ts.

The live blocker is now provisioning and wiring, not the crypto library —
apps/auth-server/src/app/crypto-capabilities.ts:79 hardcodes ES256: false and :95
responseEncryption: false, and the comments there state exactly why: no env var, schema field,
JWKS entry or code path exists to provision a P-256 signing key, and there is no
direct_post.jwt intake route to decrypt at. Both are attributed there to #233/#234.
wallet-federation-haip.integration.test.ts:20-27 carries the same stale reasoning.

The conclusion is unchanged (haip-1.0 cannot boot, and must not), only the reason moved.
Correcting it matters because someone reading the document today would wait for an issue that
already closed.

Tasks

Stage 0 — the cheapest independent counterparty first

  • Run the OpenID Foundation conformance suite's OpenID4VP verifier test plan against a
    deployed QAuth on oid4vp-1.0-base. The suite acts as the wallet; it is free to self-host
    from GitLab, and it is an implementation nobody on this project wrote. Configure QAuth's
    OID4VP_WALLET_INVOCATION_ENDPOINT to the suite's authorization_endpoint rather than
    openid4vp://.
  • Record every module result, including SKIPPED/WARNING, and file each hard failure as
    its own issue linked to epic epic: Wallet-agnostic VC federation via OID4VP 1.0 (ADR-004) #231.

Stage 1 — issuance (expect this to be the hard part)

  • Pick an issuance source and get one SD-JWT VC into a wallet you can drive. See "The
    issuance problem" — do not start Stage 2 before this is solved, because presentation
    cannot be validated without a credential the wallet will actually hold.
  • Extract that issuer's public JWK and pin it in OID4VP_ISSUER_JWKS, plus the issuer
    identifier in OID4VP_TRUSTED_ISSUERS. Record how you obtained the key (x5c leaf?
    published JWKS? out of band?) and whether it rotates — that is a finding about QAuth's
    missing resolver backends, not just setup noise.

Stage 2 — the run

  • Deploy QAuth on a host a phone can reach over publicly-trusted HTTPS, with JWT_ISSUER
    set to that exact origin — response_uri is derived from it
    (apps/auth-server/src/app/helpers/wallet-login-request.ts:140).
  • Work docs/wallet-interop-manual-validation.md's base-profile checklist (:76-165)
    top to bottom against the real wallet, recording behaviour on passes as well as failures.
  • Deploy from main/apps/auth-server/src/main.ts, not through the E2E harness — see
    "Do not test through the harness".

Stage 3 — record

  • Fill the document's findings table (:40-42) with wallet, build number, date, profile
    attempted and outcome. A "we tried X and it did not work" row is the deliverable too.
  • Correct the stale feat: [T4] Crypto prerequisites for HAIP — ES256 + JWE (ECDH-ES/A*GCM) #298 attribution (:31-34, :166) per above.
  • Add a checklist item for the rate-limit hazard: OID4VP_RESPONSE_RATE_LIMIT at its
    production default can 429 a manual run, and the checklist's own rule that refusals are
    indistinguishable on the wire (:144-145) makes a 429 easy to misread as a security
    refusal. The E2E sidesteps this by raising every limit to 10000
    (apps/auth-server/src/testing/e2e-harness.ts:138-140); a real run does not get that.
  • Decide and record whether the evidence justifies opening an OpenID4VP 1.0
    self-certification
    issue (see "Relationship to certification").

Technical Details

Candidate counterparties

Named because a category ("an EUDI wallet") is not actionable. The verified/unverified split is
the point — do not let an unverified row become an assumption, which is the failure mode
#240 and ADR-004 both already recorded.

Candidate What it is Status
OIDF conformance suite, OpenID4VP verifier plan (gitlab.com/openid/conformance-suite) An independently-written "fake wallet" that drives your verifier. Self-hostable via Docker, free for development Verified: OIDF opened OpenID4VP 1.0 self-certification on its platform 2026-02-26; the verifier flow is documented (point your verifier at the suite's authorization_endpoint, the test waits for your request)
EUDI Wallet reference implementation (eu-digital-identity-wallet on GitHub — eudi-app-android-wallet-ui, eudi-lib-android-wallet-core, and the iOS wallet kit) The flagship ecosystem's own wallet, Android + iOS Partly verified: the org, the apps and SD-JWT VC + mdoc support are real and current. Its exact OID4VP 1.0 (Final) vs. draft posture as of today is NOT verified here — confirm against the build you install before recording a result
EUDI reference issuer (issuer.eudiw.dev, dev instance dev.issuer.eudiw.dev; source eudi-srv-web-issuing-eudiw-py, eudi-srv-pid-issuer) PID / (Q)EAA issuer, mso_mdoc + SD-JWT VC, with a form-based test authentication mode Partly verified: exists and issues SD-JWT VC. Public references describe it as OpenID4VCI draft 13, not 1.0 Final — irrelevant to QAuth (we do not speak OID4VCI) but relevant to which wallet builds will accept it
Animo playground (playground.animo.id) + Paradym Wallet Hosted issuer and verifier implementing OID4VP 1.0, SD-JWT VC, mdoc and most of HAIP Partly verified: the playground and its claimed coverage are real. Best current candidate for solving Stage 1 and Stage 2 with one counterparty — verify the claim yourself before relying on it
walt.id (waltid-identity) The reference implementation ADR-004 already names (docs/adr/004-wallet-agnostic-federation.md:27, :420) Partly verified: their "verifier 2" is described as fully OID4VP v1 compliant with the old verifier deprecated in Q2 2026. Their wallet's OID4VP 1.0 posture is a separate question and is not verified here
wwWallet, Lissi, Sphereon (ADR-004 :19, :93) Other named ecosystem wallets Unverified. Named for completeness only. Do not attempt one of these without first checking its current OID4VP posture

A wallet is a phone application. localhost is not reachable from it — the document's
"Before you start" (:44-49) already covers the tunnel/deploy requirement and the JWT_ISSUER
constraint, and it is correct.

The issuance problem — call it out rather than glossing it

You cannot validate presentation without an issuer the wallet will accept. QAuth is a
Verifier and issues nothing; the document states this (:51-54) but does not solve it, and it
is the step most likely to consume the spike's budget.

Two constraints have to be satisfied simultaneously:

  1. The wallet must accept the credential. Ecosystem wallets are frequently pinned to a
    specific issuer, trust list or protocol draft. An arbitrary SD-JWT VC minted with jose will
    not necessarily be storable in an EUDI reference build.
  2. QAuth must be able to pin the issuer's key. Today the only resolver is the static
    OID4VP_ISSUER_JWKS map (issuer-key-resolution.ts:152). Real ecosystem issuers convey keys
    via x5c and rotate them. There is no x5c backend and no issuer-metadata backend, despite
    both being declared in the profile (verifier-profiles.ts:41). So the operator must extract a
    leaf public key by hand, and the pass breaks the moment the issuer rotates.

Three paths, in rising cost:

  • A hosted playground issuer (Animo) that the same vendor's wallet already trusts — solves
    (1) cheaply; (2) still needs a manual key extraction.
  • The EUDI dev issuer with the EUDI reference wallet — closest to the flagship ecosystem, and
    the highest-value evidence; likely the most setup.
  • Self-hosting an issuer (walt.id, or the EUDI issuer source) — full control of the key, so
    (2) is trivial; but you must then convince a real wallet to trust it, which reintroduces (1).

If constraint (2) turns out to be the wall rather than the annoyance, that is itself the
headline finding
: it means an x5c issuer-key resolution backend is a prerequisite for any
real-ecosystem deployment, and it should be filed as a blocking issue against epic #231 rather
than worked around locally for the duration of the run.

What a pass settles that the mock cannot

The mock and the verifier share one author's reading of the spec. These are the claims that
reading cannot support, and that this exercise buys:

  1. Request construction is consumable by software we did not write. A real wallet parses our
    invocation URI, dcql_query, and client_metadata — or rejects it. The mock's
    parseOid4vpRequest (mock-wallet.ts:313) was written against our emitter.
  2. The vp_token shape we demand is the shape wallets emit. The suite already asserts we
    read only a DCQL-keyed object and refuse a Presentation Exchange envelope
    (wallet-federation.integration.test.ts:821-822). Whether a shipping wallet agrees is
    currently unknown, and the document flags exactly this as an interop finding rather than a
    QAuth bug (:107-110).
  3. SD-JWT VC encoding agreement — disclosure encoding, _sd_alg, cnf, and Key Binding JWT
    aud/nonce construction against a credential we did not mint.
  4. The consent screen renders our client_metadata as something a human can act on.
  5. Real-transport behaviour: HTTPS, cross-device QR handoff, __Host- cookie enforcement by
    an actual browser, and a form-encoded direct_post from an unauthenticated mobile network
    path. app.inject() exercises none of it.
  6. The refusal paths hold against real inputs, not synthesised ones — in particular the
    ADR-009 §1 case the document calls a full authentication bypass if it succeeds (:130-133).

Do not test through the E2E harness

apps/auth-server/src/testing/e2e-harness.ts:184 registers the app as fp(app), whereas
production registers it plain (apps/auth-server/src/main.ts:119). The harness's comment
(:179-183) is honest about it and argues the difference is encapsulation-only — db/dbPool/
redis land on the root instance so resetE2eState (:88-91) can truncate the database the
server is actually using. That reasoning is sound for the assertions the suite makes.

It is still a real-assembly difference, and it is not the only one:

  • main.ts:86-116 registers @fastify/swagger (always) before the app; the harness registers
    neither swagger nor swagger-ui.
  • main.ts:37-39 sets requestIdHeader, logController and genReqId; the harness sets none.
  • The harness raises every rate limit to 10000 (:136-141).

Inference, not proof: none of these is known to affect wallet-flow behaviour, and I did not
find a case where they do. But #370 is the standing precedent for exactly this class — a defect
invisible to lint, typecheck and every test because they all resolve through the workspace, and
only the packaged artifact boots differently. An interop run whose purpose is to test the real
assembly should test the real assembly: deploy the image, run main.ts.

Decision: counterparty ordering

Two defensible sequences, and the answer changes the budget by a large factor:

  • (a) Conformance suite first, wallet second. The OIDF suite is self-hostable, scriptable,
    and needs no phone, no tunnel and no issuance — it drives our verifier directly. It would
    surface protocol-level defects at a fraction of the cost, leaving the phone run to test only
    what a synthetic wallet cannot (UI, transport, human consent).
  • (b) Real wallet first. Higher-fidelity evidence, and the only thing that answers "does a
    shipping wallet interoperate with us"; but gated on the issuance problem above, which may not
    resolve quickly.

Recommendation: (a), then (b). Sequencing them this way means a phone run starts from a
verifier already known to be protocol-clean, so any failure it finds is attributable to
transport, UI or ecosystem policy rather than to our request construction. It also means the
spike produces some independent evidence even if Stage 1 stalls. Owner decides at
implementation time.

Decision: where the evidence lives

docs/wallet-interop-manual-validation.md is the obvious home and already has the table. But a
conformance-suite run produces per-module results that look much more like
docs/oidf-op-certification-runbook.md — which is the existing precedent for "operator-facing
runbook with file:line anchors and [RE-CHECK AT RUN TIME] markers".

Options: extend the existing manual-validation doc with a suite section, or add a sibling
docs/oid4vp-interop-runbook.md modelled on the OP runbook and cross-link. Recommendation:
extend the existing document for the wallet findings table (it is already the named reporting
target, :188-193) and add the suite runbook separately only if Stage 0 actually happens — do
not create an empty second document.

Relationship to certification (#286)

Correction worth stating explicitly: #286 is CLOSED, sits on the T3 milestone, and is
about OP certification (Basic OP + Config OP). It deliberately scoped OID4VP/OID4VCI/HAIP
self-certification out, noting that programme went live 2026-02-26.

So this issue does not feed #286. What it does is produce the prerequisite for a separate,
currently unowned
track: there is no issue for OpenID4VP 1.0 self-certification. #286 and
docs/oidf-op-certification-runbook.md are the precedent — that runbook is what a
certification-grade artifact looks like in this repo, and Stage 0 of this issue is the same
motion (deploy publicly, drive the OIDF suite, export results) against a different test plan.
Stage 3 asks for an explicit decision on whether to open that issue, rather than opening it
speculatively.

Acceptance Criteria

Additional Notes

  • Scope is oid4vp-1.0-base only. A haip-1.0 pass stays blocked — see the correction above —
    and remains covered by the document's second checklist (:166-186) plus feat: [T4] Interoperable Key Attestations validation — HAIP §9.2 (ADR-004) #308.
  • Nothing in this issue authorises weakening a refusal to make a wallet work. If a real wallet
    fails a security check, the finding is recorded and triaged; the check is not relaxed to
    produce a green run.

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Backlog

Relationships

None yet

Development

No branches or pull requests

Issue actions