Skip to content

Feat/ts12 operator page merge - #843

Open
masv3971 wants to merge 32 commits into
mainfrom
feat/ts12-operator-page-merge
Open

masv3971 wants to merge 32 commits into
mainfrom
feat/ts12-operator-page-merge

Conversation

@masv3971

Copy link
Copy Markdown
Member

No description provided.

leifj and others added 30 commits October 15, 2026 22:13
…ction

The verifier could encode transaction_data but nothing ever supplied it:
CreateRequestObject's only caller passed nil, the request struct had no
payload or transaction_data_hashes_alg, and the session did not keep the
strings that were sent, so there was nothing to validate a response against.

- openid4vp.TransactionData gains payload and transaction_data_hashes_alg
  (an array, always sent: TS12 4.2 makes it REQUIRED).
- openid4vp.NewTS12PaymentTransactionData builds urn:eudi:sca:payment:1
  with the schema's required members (transaction_id 1..36, payee name+id,
  ISO 4217 currency, amount as a JSON number > 0) and refuses the rest.
- /ui/interaction accepts an optional "payment" object (verifier.ts12.enable,
  default off). The credential query it binds to must be dc+sd-jwt with
  holder binding; anything else is refused with 400.
- The encoded strings are stored on the AuthorizationContext and sent in
  the request object (and its DC API variant), identical bytes.
- A payment session is never reused, and a payment session is never served
  to a request that did not ask for one.
- Golden vectors from the WMP spec repo pin the hash input (raw string).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ponse_mode)

The validator already compared transaction_data_hashes, but no caller ever set
TransactionData, so a payment request was never checked. Wire it into the
standalone verifier's direct_post path and add what TS12 v1.0.1 3.6 asks of the
KB-JWT.

- Per-credential scoping: a presentation owes the hashes of the entries whose
  credential_ids reference its query id, in request order; every entry must be
  covered by some presented credential (OpenID4VP and TS12 leave the scope
  open, noted in the PR).
- transaction_data_hashes_alg: a string in the KB-JWT, one the request allowed,
  sha-256 when the request named none; an array (the wallet-common shape) is
  refused. Hashes are over the strings as sent.
- TS12 claims, applied when an entry is a urn:eudi:sca:* type: amr as an array
  of single-key objects from the closed v1.0.1 vocabulary with >= 2 different
  categories; response_mode echoing one the request was issued with; jti
  present and never seen before (replay cache, atomic SetNX).
- Fail closed: no replay store, no configured response modes, an unreadable
  entry, or a non-SD-JWT answer to a bound query all refuse with
  invalid_transaction_data.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
/payment (verifier.ts12.enable) is a small form - amount, currency, payee,
SCA attestation vct - that posts a payment to /ui/interaction, shows the QR
code and wallet link, and reports whether the wallet's response was accepted.
docs/TS12_PAYMENT_SCA.md gives the same flow as curl commands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Reject a credential_id that names more than one credential query instead of
  silently binding the last one.
- Count transaction_id characters as code points (JSON Schema maxLength),
  not UTF-8 bytes.
- Check currency against an ISO 4217 alpha-3 snapshot instead of any three
  capital letters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ng (review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Review: the KB-JWT response_mode must be checked against what the session
was issued with, not the receiving node's current configuration. Record the
modes on the AuthorizationContext at issuance (B reads them). The payment
builder also insists the bound query names the attestation with
meta.vct_values, which B compares against the presented credential's vct.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ued response modes (review)

- Refuse a credential whose vct is not one of the bound query's vct_values
  (trust evaluation does not enforce the requested type).
- Check response_mode against the modes recorded on the session at issuance,
  not the receiving node's current configuration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…sult fetch (review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…sued modes (review)

- Return the shared pkg/oauth2.OAuthError so RegEndpoint renders the status
  and description (the verifier-local type fell through to the generic
  handler).
- A wrongly-typed or truncated /ui/interaction body is a 400, not a 500.
- Resuming a payment session does not issue a DC API variant the session was
  not issued with.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed (review)

Validate no longer touches the replay store: it reports PresentedJTI, and the
direct_post handler claims it after issuer trust and the vct check. A
fabricated credential can no longer reserve chosen jti values.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…iew)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…pper guess (review)

json.Number made the JSON decoder reject "token" with a plain error that the
status mapper read as 401/404. PaymentAmount keeps the literal and the
payment builder refuses it as invalid_request.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…efused (review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
PresentedJTI is an output, and the caller CLAIMS a non-empty one in a
JTIStore after issuer authentication - a state change that refuses any
later presentation carrying that jti. A validator reused for a second
presentation that reports no jti kept the first one, so the caller
claimed it again and rejected an unrelated, valid presentation as a
replay.

Cleared at the top of Validate rather than at the point of use: the
field is written only on the SCA path, so every path that does not
write it has to leave it empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Both DCQL refusals returned an unstructured error, which carries no
status, so httphelpers.StatusCode inferred one from the message text -
and the message names the credential query id, which the CALLER chose.
A query named "token" matched the "unauthorized, authentication, token"
case and was answered 401, before the "invalid" case was reached, with
the description dropped on the way out. A caller must not be able to
choose the status code of its own refusal.

The empty-DCQL refusal was worse than the reported case: with nothing
in its text to infer from it fell through to 500.

Payment requests keep the "payment:" prefix, so one refused by this
loop - before buildPaymentTransactionData runs - reads like every other
payment refusal rather than like a different class of failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
One decode with an empty fallback served both outcomes, so a successful
/ui/interaction response whose body could not be read left reply = {}.
The page then set the QR image to "data:image/png;base64,undefined",
linked to "undefined" and polled session_id=undefined 150 times: the
operator sees a payment waiting for a wallet that was never asked for
anything.

The fallback now lives on the error branch, where a non-JSON body is
legitimate. A successful response must decode, and must carry the three
members the page goes on to use - a 200 with a well-formed body missing
session_id did exactly the same thing as a truncated one.

There is no JS test runner here, so the regression test pins the
structure of the handler instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
…device

The link was an ordinary anchor, so no session was ever marked
same-device and /verification/direct_post returned no redirect_uri: the
wallet had nowhere to send the user back to, and the operator had to
find the polling page by hand.

It hands off through wallet-handoff.js rather than a second copy of it.
That ordering is not incidental - Chrome on Android only lets a page
launch a custom scheme while it still holds the click's transient
activation, and awaiting the preference POST first loses it silently,
with no exception and no intent.

Armed on the LINK only. Scanning the QR code is a cross-device flow and
must not be marked same-device, and a modified click opens somewhere
else, which is not this browser following the redirect back.

The arming lives in payment-handoff.js so it can be driven by
node --test like the handoff it wraps: which clicks hand off, what the
preference says, that the href is read at click time, and that nothing
is awaited before a custom-scheme launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarCloud's quality gate failed on new_reliability_rating, from the
one reliability issue in the new code: handOffToWallet's promise was
neither awaited nor caught. A click handler cannot await it - that is
the whole point of the module - so the rejection is caught instead, and
a failed handoff is a console line rather than an unhandled rejection
nobody sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decoder reports two errors for "there is no value here": io.EOF
when the body ends before one starts, io.ErrUnexpectedEOF when it stops
part-way through. Only the second was mapped, so a whitespace-only body
with a positive Content-Length fell through to the status mapper - no
status to read - and the caller was told the verifier had broken.

Probing that turned up the worse case next to it. The binding skips
decoding a body of ZERO length entirely: no decode, no validation, no
error. So an empty POST reached UIInteraction as a zero-valued request,
and everything there dereferences the query - a nil pointer
dereference, a stack trace, and a 500 where a structured 400 was owed.
Guarded in apiv1 rather than at the endpoint, so every caller of
UIInteraction gets it and the rule lives where the requirement does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every multi-entry case here used entries carrying identical defaults,
so the intersection was never exercised: reading only the first entry's
list, or unioning the lists instead of intersecting them, passed all of
them.

The new cases make the entries disagree on purpose - overlapping,
disjoint, and an explicit list paired with an absent one - and check
both directions, since a union bug shows up as an ACCEPTED algorithm
only one entry allowed. That is a wallet hashing with something the
other entry's issuer never agreed to.

Each entry carries a distinct payload, so an entry silently dropped is
a count mismatch rather than a coincidental pass. The overlapping case
also asserts that the first entry ALONE would have allowed sha-256,
which is what makes refusing it meaningful rather than a blanket no.

Probed both regressions the coverage gap hid: first-entry-only and
union each fail all four subtests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
leifj and others added 2 commits October 19, 2026 21:48
A wallet response refused for transaction_data reached the status
mapper as unstructured text, so its status was guessed from the
message: "missing" answered 404 and "VP Token" matched "token" for
401. Refusals now go through invalidTransactionData, a structured
oauth2.OAuthError with invalid_transaction_data and status 400. This
covers the validator's transaction_data failures, an unreadable
session entry, a non-SD-JWT answer, an uncovered entry, the vct check
and the jti check.

The jti was claimed inside the per-credential loop, so a response
refused later (another credential, the coverage check) had already
used up its jti. The jti values are now collected during the loop and
claimed only after every check has passed.

Remove the unused openid4vp.JTIStore interface and the comments that
referred to it.

Tests: every TS12 refusal case now also asserts 400, and a new test
checks that a refusal after the SCA credential passes leaves its jti
unclaimed.
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants