From 1b64027c64f2caae1f0aca96e75faca3e248228e Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:13:09 +0200 Subject: [PATCH 01/20] feat(verifier): send EC TS12 payment transaction_data from /ui/interaction 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 --- docs/CONFIGURATION.md | 11 ++ internal/verifier/apiv1/handler_ts12.go | 93 +++++++++++ internal/verifier/apiv1/handler_ts12_test.go | 125 +++++++++++++++ internal/verifier/apiv1/handlers_ui.go | 48 +++++- pkg/cache/authcontext_types.go | 5 + pkg/model/config.go | 22 +++ pkg/openid4vp/request_object.go | 6 + .../ts12/transaction-data-hashes.json | 149 ++++++++++++++++++ pkg/openid4vp/ts12.go | 123 +++++++++++++++ pkg/openid4vp/ts12_test.go | 137 ++++++++++++++++ 10 files changed, 712 insertions(+), 7 deletions(-) create mode 100644 internal/verifier/apiv1/handler_ts12.go create mode 100644 internal/verifier/apiv1/handler_ts12_test.go create mode 100644 pkg/openid4vp/testdata/ts12/transaction-data-hashes.json create mode 100644 pkg/openid4vp/ts12.go create mode 100644 pkg/openid4vp/ts12_test.go diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b3ef80017..7f0a0f150 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1530,6 +1530,7 @@ Configuration for the Verifier service that verifies credentials and acts as an | `outbound` | `object` | Outbound groups outbound identity assertion | - | - | No | | `digital_credentials` | `object` | W3C Digital Credentials API configuration | - | - | No | | `authorization_page_css` | `object` | Authorization page styling configuration | - | - | No | +| `ts12` | `object` | EC TS12 payment SCA (transaction_data) support for the standalone verifier. Off by default. | - | - | No | | `credential_display` | `object` | Credential display settings | - | - | No | | `trust` | `object` | Trust evaluation configuration | - | - | No | | `federation` | `object` | OpenID Federation entity configuration. When enabled, serves /.well-known/openid-federation as a self-signed JWT. | - | - | No | @@ -1730,6 +1731,16 @@ These clients are checked in addition to dynamically registered clients stored i | `title` | `string` | Title overrides the page title (default: "Wallet Authorization") | - | - | No | | `subtitle` | `string` | Subtitle overrides the page subtitle | - | - | No | +### `ts12` + +> **Path:** `.verifier.ts12` + +| Field | Type | Description | Example | Default | Required | +| ---------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------- | -------- | +| `enable` | `bool` | Enable lets /ui/interaction accept a "payment" object and send a urn:eudi:sca:payment:1 transaction_data entry with the request. It also serves the operator test page at /payment. Intended for test and PSP verifiers: anyone who can reach /ui/interaction can then start a payment request, so leave it off on a general-purpose verifier. | - | `false` | No | +| `sca_vct_values` | `[]string` | Vct values of the SCA attestation the operator test page requests by default. Optional; the page lets the operator type one. | - | - | No | +| `hash_algs` | `[]string` | Transaction_data_hashes_alg list sent with each payment request, in order of preference. Default ["sha-256"] (OpenID4VP 1.0 B.3.3.1 requires every implementation to support it; TS12 4.2 makes the member REQUIRED so it is always sent). | - | - | No | + ### `credential_display` > **Path:** `.verifier.credential_display` diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go new file mode 100644 index 000000000..3e7d1159d --- /dev/null +++ b/internal/verifier/apiv1/handler_ts12.go @@ -0,0 +1,93 @@ +package apiv1 + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/SUNET/vc/pkg/openid4vp" + + "github.com/google/uuid" +) + +// UIPaymentRequest asks /ui/interaction to attach an EC TS12 payment +// transaction_data entry (urn:eudi:sca:payment:1) to the request. It is the +// operator-facing shape; the wire entry is built from it by +// buildPaymentTransactionData, never the other way round. +type UIPaymentRequest struct { + // Amount is the payment amount in major currency units, as a JSON number or a numeric string ("49.99"). Must be greater than zero. + Amount json.Number `json:"amount" validate:"required"` + // Currency is an ISO 4217 alpha-3 code. + Currency string `json:"currency" validate:"required"` + // PayeeName and PayeeID are REQUIRED by the TS12 payment schema. + PayeeName string `json:"payee_name" validate:"required,max=256"` + PayeeID string `json:"payee_id" validate:"required,max=256"` + PayeeWebsite string `json:"payee_website,omitempty" validate:"omitempty,max=512"` + // TransactionID is the relying party's identifier of the interaction (1..36 characters). A fresh UUID when omitted. + TransactionID string `json:"transaction_id,omitempty" validate:"omitempty,max=36"` + // CredentialID is the DCQL credential query id of the SCA attestation that authorizes the payment. Required when the DCQL query has more than one credential query. + CredentialID string `json:"credential_id,omitempty" validate:"omitempty,max=128"` +} + +func invalidPayment(format string, args ...any) error { + return &OAuthError{ + ErrorCode: ErrCodeInvalidRequest, + ErrorDescription: "payment: " + fmt.Sprintf(format, args...), + HTTPStatus: http.StatusBadRequest, + } +} + +// buildPaymentTransactionData validates a payment request against the DCQL +// query and returns the base64url transaction_data strings to send. It fails +// closed: TS12 v1.0.1 covers SD-JWT VC only, so anything that cannot be bound +// to a holder-bound SD-JWT VC credential query is refused rather than sent. +func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4vp.DCQL) ([]string, error) { + if !c.cfg.Verifier.TS12.Enable { + return nil, invalidPayment("TS12 payment requests are not enabled on this verifier (verifier.ts12.enable)") + } + if dcql == nil || len(dcql.Credentials) == 0 { + return nil, invalidPayment("a DCQL query with the SCA attestation is required") + } + + var target *openid4vp.CredentialQuery + if p.CredentialID == "" { + if len(dcql.Credentials) != 1 { + return nil, invalidPayment("credential_id is required when the DCQL query has more than one credential query") + } + target = &dcql.Credentials[0] + } else { + for i := range dcql.Credentials { + if dcql.Credentials[i].ID == p.CredentialID { + target = &dcql.Credentials[i] + } + } + if target == nil { + return nil, invalidPayment("credential_id %q does not name a credential query", p.CredentialID) + } + } + if target.Format != openid4vp.FormatSDJWTVC { + return nil, invalidPayment("credential query %q has format %q; TS12 payment SCA requires %q", target.ID, target.Format, openid4vp.FormatSDJWTVC) + } + if !target.RequiresCryptographicHolderBinding() { + return nil, invalidPayment("credential query %q disables cryptographic holder binding, which transaction_data requires", target.ID) + } + + txID := p.TransactionID + if txID == "" { + txID = uuid.NewString() + } + td, err := openid4vp.NewTS12PaymentTransactionData(openid4vp.TS12PaymentPayload{ + TransactionID: txID, + Payee: openid4vp.TS12Payee{Name: p.PayeeName, ID: p.PayeeID, Website: p.PayeeWebsite}, + Currency: p.Currency, + Amount: openid4vp.RawNumber(p.Amount.String()), + }, []string{target.ID}, c.cfg.Verifier.TS12.HashAlgs) + if err != nil { + return nil, invalidPayment("%v", err) + } + encoded, err := td.Base64Encode() + if err != nil { + return nil, fmt.Errorf("encoding transaction_data: %w", err) + } + return []string{encoded}, nil +} diff --git a/internal/verifier/apiv1/handler_ts12_test.go b/internal/verifier/apiv1/handler_ts12_test.go new file mode 100644 index 000000000..5705669f3 --- /dev/null +++ b/internal/verifier/apiv1/handler_ts12_test.go @@ -0,0 +1,125 @@ +package apiv1 + +import ( + "encoding/base64" + "encoding/json" + "errors" + "testing" + + "github.com/SUNET/vc/pkg/openid4vp" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func scaDCQL(id string) *openid4vp.DCQL { + return &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: id, + Format: openid4vp.FormatSDJWTVC, + Meta: openid4vp.MetaQuery{VCTValues: []string{"https://psp.example/sca"}}, + }}} +} + +func testPayment() *UIPaymentRequest { + return &UIPaymentRequest{Amount: "49.99", Currency: "EUR", PayeeName: "Shop AB", PayeeID: "SE1234567890"} +} + +func TestUIInteraction_PaymentSendsAndRemembersTheSameStrings(t *testing.T) { + ctx := t.Context() + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + + reply, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), Payment: testPayment()}) + require.NoError(t, err) + + authCtx, err := client.cacheService.AuthContext.GetByID(ctx, reply.SessionID) + require.NoError(t, err) + require.Len(t, authCtx.TransactionData, 1) + + ro, ok := client.cacheService.RequestObject.Get(ctx, authCtx.RequestObjectID) + require.True(t, ok) + assert.Equal(t, authCtx.TransactionData, ro.TransactionData, "the session must hold the very strings the wallet was sent") + + dec, err := base64.RawURLEncoding.DecodeString(ro.TransactionData[0]) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(dec, &m)) + assert.Equal(t, openid4vp.TS12TypePayment, m["type"]) + assert.Equal(t, []any{"sca"}, m["credential_ids"]) + assert.Equal(t, []any{"sha-256"}, m["transaction_data_hashes_alg"]) + payload := m["payload"].(map[string]any) + assert.Equal(t, 49.99, payload["amount"]) + assert.Equal(t, "EUR", payload["currency"]) + assert.NotEmpty(t, payload["transaction_id"]) +} + +func TestUIInteraction_PaymentRefusals(t *testing.T) { + ctx := t.Context() + + multi := scaDCQL("sca") + multi.Credentials = append(multi.Credentials, openid4vp.CredentialQuery{ID: "age", Format: openid4vp.FormatSDJWTVC, Meta: openid4vp.MetaQuery{VCTValues: []string{"x"}}}) + mdoc := &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ID: "m", Format: "mso_mdoc", Meta: openid4vp.MetaQuery{DoctypeValue: "org.iso.18013.5.1.mDL"}}}} + noBinding := scaDCQL("sca") + f := false + noBinding.Credentials[0].RequireCryptographicHolderBinding = &f + + cases := map[string]struct { + enable bool + dcql *openid4vp.DCQL + mut func(p *UIPaymentRequest) + }{ + "feature disabled": {false, scaDCQL("sca"), nil}, + "two queries without credential_id": {true, multi, nil}, + "unknown credential_id": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.CredentialID = "nope" }}, + "mdoc is out of TS12 scope": {true, mdoc, nil}, + "holder binding disabled": {true, noBinding, nil}, + "bad currency": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Currency = "euro" }}, + "zero amount": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Amount = "0" }}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = tc.enable + p := testPayment() + if tc.mut != nil { + tc.mut(p) + } + _, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: tc.dcql, Payment: p}) + require.Error(t, err) + oe, ok := errors.AsType[*OAuthError](err) + require.True(t, ok, "a client mistake must be a 400, got %v", err) + assert.Equal(t, 400, oe.HTTPStatus) + }) + } + + t.Run("multi query accepted when credential_id names the SCA one", func(t *testing.T) { + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + p := testPayment() + p.CredentialID = "sca" + _, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: multi, Payment: p}) + require.NoError(t, err) + }) +} + +// A payment is a new transaction each time: neither a payment request nor a +// plain request may be answered with another transaction's session. +func TestUIInteraction_PaymentSessionsAreNeverReused(t *testing.T) { + ctx := t.Context() + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + + first, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), Payment: testPayment()}) + require.NoError(t, err) + + again, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), Payment: testPayment(), SessionID: first.SessionID}) + require.NoError(t, err) + assert.NotEqual(t, first.SessionID, again.SessionID) + + plain, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), SessionID: first.SessionID}) + require.NoError(t, err) + assert.NotEqual(t, first.SessionID, plain.SessionID, "a non-payment request must not be served a payment session") + authCtx, err := client.cacheService.AuthContext.GetByID(ctx, plain.SessionID) + require.NoError(t, err) + assert.Empty(t, authCtx.TransactionData) +} diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go index 47794a0d8..fe3307ccb 100644 --- a/internal/verifier/apiv1/handlers_ui.go +++ b/internal/verifier/apiv1/handlers_ui.go @@ -427,6 +427,13 @@ type UIInteractionRequest struct { // reload mid-flow does not orphan the wallet's outstanding scan. // Otherwise a fresh session id is minted and returned in the reply. SessionID string `json:"session_id,omitempty" validate:"omitempty,max=128,printascii"` + + // Payment, when set, makes this an EC TS12 payment SCA request: a + // urn:eudi:sca:payment:1 transaction_data entry is added to the request + // and the response is validated against it. Requires verifier.ts12.enable. + // A payment is never served from a reused session: every payment request + // is a new transaction. + Payment *UIPaymentRequest `json:"payment,omitempty" validate:"omitempty"` } type UIInteractionReply struct { @@ -467,6 +474,12 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( // same shape before we can compare them for equality. c.augmentDCQLFromVCTM(req.DCQLQuery) + // A payment is a new transaction every time, so it never joins a reused + // session; tryReuseInFlightSession in turn refuses to hand a payment + // session to a request that did not ask for one. + if req.Payment != nil { + req.SessionID = "" + } if reply, ok, err := c.tryReuseInFlightSession(ctx, req); err != nil { return nil, err } else if ok { @@ -510,6 +523,20 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( } } + // The entries are encoded once, here, and these exact strings go into the + // request object AND the session: OpenID4VP hashes the string as + // received, so what we compare the wallet's hashes against must be the + // bytes we sent, not a later re-encoding. + var transactionData []string + if req.Payment != nil { + var err error + transactionData, err = c.buildPaymentTransactionData(req.Payment, req.DCQLQuery) + if err != nil { + c.log.Error(err, "rejected a payment request") + return nil, err + } + } + // Collect all credential IDs from DCQL query scopes := make([]string, 0, len(req.DCQLQuery.Credentials)) for _, credential := range req.DCQLQuery.Credentials { @@ -530,13 +557,14 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( // which types were asked for, whether holder binding was required. // Without it here the request object cache is the only copy, and the // verification path cannot see what it is checking against. - DCQLQuery: req.DCQLQuery, - Code: "", - RequestURI: "", - WalletURI: "", - Forfeited: false, - State: state, - ClientID: uiClientID, + DCQLQuery: req.DCQLQuery, + TransactionData: transactionData, + Code: "", + RequestURI: "", + WalletURI: "", + Forfeited: false, + State: state, + ClientID: uiClientID, // How long the user has to complete the presentation. // // This was 0, and isSessionActive reads 0 as "never expires" - so @@ -624,6 +652,7 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( RedirectURI: "", Scope: "", DCQLQuery: req.DCQLQuery, + TransactionData: transactionData, RequestURIMethod: "", VerifierInfo: c.registrationCertificate.VerifierInfo(), } @@ -711,6 +740,11 @@ func (c *Client) tryReuseInFlightSession(ctx context.Context, req *UIInteraction if authCtx == nil { return nil, false, nil } + // A payment session belongs to its payment. Serving it to a request that + // did not ask for one would present an old transaction as a new request. + if len(authCtx.TransactionData) > 0 { + return nil, false, nil + } if !isReusableAuthContext(authCtx) { return nil, false, nil } diff --git a/pkg/cache/authcontext_types.go b/pkg/cache/authcontext_types.go index 33a5b5a94..bd9b85828 100644 --- a/pkg/cache/authcontext_types.go +++ b/pkg/cache/authcontext_types.go @@ -155,6 +155,11 @@ type AuthorizationContext struct { RequestObjectID string `json:"request_object_id,omitempty" bson:"request_object_id,omitempty" validate:"omitempty,max=128,printascii"` RequestObjectNonce string `json:"request_object_nonce,omitempty" bson:"request_object_nonce,omitempty" validate:"omitempty,max=128,printascii"` DCQLQuery *openid4vp.DCQL `json:"dcql_query,omitempty" bson:"dcql_query,omitempty"` + // TransactionData holds the transaction_data entries exactly as sent in + // the request: base64url strings, in request order. OpenID4VP 1.0 + // Appendix B hashes the string as received, so validation must use these + // bytes and never a re-encoding of a decoded object. + TransactionData []string `json:"transaction_data,omitempty" bson:"transaction_data,omitempty"` // ScopeQueryIDs maps a requested scope to the id of the DCQL credential // query that stands for it, for the scopes where the two differ. // diff --git a/pkg/model/config.go b/pkg/model/config.go index d80bc8fa2..0207b3712 100644 --- a/pkg/model/config.go +++ b/pkg/model/config.go @@ -835,6 +835,8 @@ type Verifier struct { DigitalCredentials DigitalCredentialsConfig `yaml:"digital_credentials,omitempty"` // AuthorizationPageCSS holds the authorization page styling configuration AuthorizationPageCSS AuthorizationPageCSSConfig `yaml:"authorization_page_css,omitempty"` + // TS12 configures EC TS12 payment SCA (transaction_data) support for the standalone verifier. Off by default. + TS12 TS12Config `yaml:"ts12,omitempty"` // CredentialDisplay holds the credential display settings CredentialDisplay CredentialDisplayConfig `yaml:"credential_display,omitempty"` // Trust holds the trust evaluation configuration @@ -1427,6 +1429,26 @@ func (c *OpenID4VPConfig) GenerateMetadata(ctx context.Context, issuerURL string }) } +// TS12Config configures EC TS12 v1.0.1 payment SCA support in the verifier. +type TS12Config struct { + // Enable lets /ui/interaction accept a "payment" object and send a + // urn:eudi:sca:payment:1 transaction_data entry with the request. It also + // serves the operator test page at /payment. Intended for test and PSP + // verifiers: anyone who can reach /ui/interaction can then start a + // payment request, so leave it off on a general-purpose verifier. + Enable bool `yaml:"enable" default:"false"` + + // SCAVCTValues are the vct values of the SCA attestation the operator test + // page requests by default. Optional; the page lets the operator type one. + SCAVCTValues []string `yaml:"sca_vct_values,omitempty"` + + // HashAlgs is the transaction_data_hashes_alg list sent with each payment + // request, in order of preference. Default ["sha-256"] (OpenID4VP 1.0 + // B.3.3.1 requires every implementation to support it; TS12 4.2 makes the + // member REQUIRED so it is always sent). + HashAlgs []string `yaml:"hash_algs,omitempty" validate:"omitempty,dive,oneof=sha-256 sha-384 sha-512"` +} + // DigitalCredentialsConfig holds W3C Digital Credentials API configuration type DigitalCredentialsConfig struct { // Enable toggles W3C Digital Credentials API support in browser diff --git a/pkg/openid4vp/request_object.go b/pkg/openid4vp/request_object.go index c2fd05cb5..848e37f58 100644 --- a/pkg/openid4vp/request_object.go +++ b/pkg/openid4vp/request_object.go @@ -168,6 +168,12 @@ type TransactionData struct { // CredentialIDS REQUIRED. Credential query IDs that can authorize this transaction. CredentialIDS []string `json:"credential_ids,omitempty" bson:"credential_ids,omitempty" validate:"required,dive,required"` + + // TransactionDataHashesAlg OPTIONAL in OpenID4VP 1.0 (Appendix B.3.3.1), REQUIRED by EC TS12 v1.0.1 section 4.2. An ARRAY of hash algorithm names, one of which the wallet MUST use for transaction_data_hashes. Absent means sha-256. + TransactionDataHashesAlg []string `json:"transaction_data_hashes_alg,omitempty" bson:"transaction_data_hashes_alg,omitempty"` + + // Payload is the transaction details (EC TS12 section 4.2): a JSON object described by the schema of the transaction type. Left opaque here; see TS12PaymentPayload for the payment type. + Payload any `json:"payload,omitempty" bson:"payload,omitempty"` } // Base64Encode encodes the TransactionData struct into a base64 URL-encoded string. diff --git a/pkg/openid4vp/testdata/ts12/transaction-data-hashes.json b/pkg/openid4vp/testdata/ts12/transaction-data-hashes.json new file mode 100644 index 000000000..b7423abe9 --- /dev/null +++ b/pkg/openid4vp/testdata/ts12/transaction-data-hashes.json @@ -0,0 +1,149 @@ +{ + "description": "Hash vectors for OID4VP transaction_data (EC TS12 payment SCA), OpenID4x profile Section 3.2.5. `raw` is the base64url string a verifier puts in the request's transaction_data array and `json` is what it decodes to. `hashes` is base64url(HASH(ASCII bytes of raw)) per OID4VP 1.0 Appendix B ('base64url decoding is not performed before hashing'), computed with Python hashlib, independent of any wallet or verifier. `noncanonical` marks entries whose JSON a re-serializer would not reproduce, so hashing a decoded-and-re-encoded object gives a different value. Vectors whose note starts HASH-ONLY have payloads that do NOT satisfy the TS12 payment schema (api/ts12-urn-eudi-sca-payment-1-data-model.json: amount is a number, payee requires name and id, transaction_id 1..36 characters, additionalProperties false); use them only to test hashing. The `conforming_*` vectors satisfy it and can drive end-to-end validation. These are not JSON-RPC conformance vectors and do not use vector-schema.json.", + "vectors": [ + { + "name": "sorted_compact", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). keys sorted at every level, no whitespace: the one shape a naive re-serializer reproduces", + "json": "{\"credential_ids\":[\"pay\"],\"payload\":{\"amount\":\"49.99\",\"currency\":\"EUR\",\"execution_date\":\"2026-10-06\",\"payee\":{\"id\":\"SE1234567890\",\"name\":\"Shop AB\"},\"transaction_id\":\"tx-0001\"},\"type\":\"urn:eudi:sca:payment:1\"}", + "raw": "eyJjcmVkZW50aWFsX2lkcyI6WyJwYXkiXSwicGF5bG9hZCI6eyJhbW91bnQiOiI0OS45OSIsImN1cnJlbmN5IjoiRVVSIiwiZXhlY3V0aW9uX2RhdGUiOiIyMDI2LTEwLTA2IiwicGF5ZWUiOnsiaWQiOiJTRTEyMzQ1Njc4OTAiLCJuYW1lIjoiU2hvcCBBQiJ9LCJ0cmFuc2FjdGlvbl9pZCI6InR4LTAwMDEifSwidHlwZSI6InVybjpldWRpOnNjYTpwYXltZW50OjEifQ", + "noncanonical": false, + "hashes": { + "sha-256": "XEnozbVb0_f-Waw7ZL3mnOtZ8wt4hEH9gI7f6FDPoJw", + "sha-384": "WXVN8LVva2ORsW6jNcCHZEl0MHKJwFUx8Fgcsco3k58c90m5gllNCtMzC0rO1PCQ", + "sha-512": "vyq6gRMULFC6Ky0-sMCzCz-P-UwZHuCmCIbhxOEV9M-NdfNWQLanqIsKBsPQhKt4XmSHxf_T-oYmwEv45EqYXg" + } + }, + { + "name": "keys_in_spec_order", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). type first, payload keys unsorted; Go's map re-marshal sorts keys", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"payload\":{\"transaction_id\":\"tx-0001\",\"payee\":{\"name\":\"Shop AB\",\"id\":\"SE1234567890\"},\"amount\":\"49.99\",\"currency\":\"EUR\",\"execution_date\":\"2026-10-06\"}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJwYXlsb2FkIjp7InRyYW5zYWN0aW9uX2lkIjoidHgtMDAwMSIsInBheWVlIjp7Im5hbWUiOiJTaG9wIEFCIiwiaWQiOiJTRTEyMzQ1Njc4OTAifSwiYW1vdW50IjoiNDkuOTkiLCJjdXJyZW5jeSI6IkVVUiIsImV4ZWN1dGlvbl9kYXRlIjoiMjAyNi0xMC0wNiJ9fQ", + "noncanonical": true, + "hashes": { + "sha-256": "d4up91CnefFMyilR37pBM743RezMNKvBr5-Cc9F_GzY", + "sha-384": "y6OQtBXxf4i_U1g5uy3JKxuqy6RZUlyLrUKhyh8Aj1dUHz_ttS5QHh2Q-Chz7c8V", + "sha-512": "P08uCUJuuQWRN__C9lNKD_B5nUSLR3HZ6T8hObQeNny_tl19W4soe6G9qz4Elb5LDswfHgI-SPTQ2uodvSnIUw" + } + }, + { + "name": "pretty_printed", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). indented with newlines; any re-serialization drops the whitespace", + "json": "{\n \"type\": \"urn:eudi:sca:payment:1\",\n \"credential_ids\": [\n \"pay\"\n ],\n \"payload\": {\n \"transaction_id\": \"tx-0001\",\n \"payee\": {\n \"name\": \"Shop AB\",\n \"id\": \"SE1234567890\"\n },\n \"amount\": \"49.99\",\n \"currency\": \"EUR\",\n \"execution_date\": \"2026-10-06\"\n }\n}", + "raw": "ewogICJ0eXBlIjogInVybjpldWRpOnNjYTpwYXltZW50OjEiLAogICJjcmVkZW50aWFsX2lkcyI6IFsKICAgICJwYXkiCiAgXSwKICAicGF5bG9hZCI6IHsKICAgICJ0cmFuc2FjdGlvbl9pZCI6ICJ0eC0wMDAxIiwKICAgICJwYXllZSI6IHsKICAgICAgIm5hbWUiOiAiU2hvcCBBQiIsCiAgICAgICJpZCI6ICJTRTEyMzQ1Njc4OTAiCiAgICB9LAogICAgImFtb3VudCI6ICI0OS45OSIsCiAgICAiY3VycmVuY3kiOiAiRVVSIiwKICAgICJleGVjdXRpb25fZGF0ZSI6ICIyMDI2LTEwLTA2IgogIH0KfQ", + "noncanonical": true, + "hashes": { + "sha-256": "B0ROp95e_urO6Tlth7uamzos64vOpR9njgNUn-5JKAk", + "sha-384": "1X1vuVkqjinBUOd7TOyZ-a4CmH6CREUTTqsak1WK5Q-RH9KbFNbNQJy2c7Vf-KEv", + "sha-512": "ef7w0fmziYQLwJjQr64p2TyR7m3TDmhojpro0XbVdA-LOyXjy5qQr_FNP6CND7r0AtcSt5Q3NTCEbOZKJIGTxA" + } + }, + { + "name": "escaped_unicode_and_slash", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). uses \\uXXXX and \\/ escapes; a re-serializer emits the literal characters instead", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"payload\":{\"transaction_id\":\"tx-0002\",\"payee\":{\"name\":\"\\u00c5kesson \\/ Son\",\"id\":\"SE1\"},\"amount\":\"10.00\",\"currency\":\"SEK\"}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJwYXlsb2FkIjp7InRyYW5zYWN0aW9uX2lkIjoidHgtMDAwMiIsInBheWVlIjp7Im5hbWUiOiJcdTAwYzVrZXNzb24gXC8gU29uIiwiaWQiOiJTRTEifSwiYW1vdW50IjoiMTAuMDAiLCJjdXJyZW5jeSI6IlNFSyJ9fQ", + "noncanonical": true, + "hashes": { + "sha-256": "WoIXcj6LDcvVcXpugPs57WSBd6W7ZYLq_5lg3o89QTk", + "sha-384": "ICddVCtQK_UXazO5cu-vGshtyRJQ6QxIussKYx8QDGD3CSZeMD5XhNhcXlZTQarn", + "sha-512": "7l6BRpwNujXDNhFOIQzNpE6sMlUyIYVHHlgX3g13P279Dx6z6JNAMTVKLPH6gRf75U6WgHWwQZ4M56mEvg_JoQ" + } + }, + { + "name": "html_sensitive_characters", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). contains & < > literally; Go's encoder escapes these to \\u0026 \\u003c \\u003e by default", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"payload\":{\"transaction_id\":\"tx-0003\",\"payee\":{\"name\":\"A&B \",\"id\":\"SE2\"},\"amount\":\"1.50\",\"currency\":\"EUR\"}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJwYXlsb2FkIjp7InRyYW5zYWN0aW9uX2lkIjoidHgtMDAwMyIsInBheWVlIjp7Im5hbWUiOiJBJkIgPFNob3A-IiwiaWQiOiJTRTIifSwiYW1vdW50IjoiMS41MCIsImN1cnJlbmN5IjoiRVVSIn19", + "noncanonical": true, + "hashes": { + "sha-256": "PrK5pZO92AECa1Wk2qct6QRUV36H5bwWIwVuMxLHz_Y", + "sha-384": "LDTXp5g8TEe_ohqnUKBBVmQ1_EVOIen9Cw1vw6RLTzeqCmX2QZeM9K-Uv1LMSAw9", + "sha-512": "54TNTKwes7k7hAIDFDXQInWb29vV9Zm1AoHbLxFElF5o1plcs9kGm6DFHvHwCLYqN6PQh0zYqYcb_t5_e751ig" + } + }, + { + "name": "hash_alg_array", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). request member transaction_data_hashes_alg is an ARRAY (OID4VP 1.0 Appendix B)", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"transaction_data_hashes_alg\":[\"sha-256\",\"sha-384\"],\"payload\":{\"transaction_id\":\"tx-0004\",\"amount\":\"5.00\",\"currency\":\"EUR\"}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiIsInNoYS0zODQiXSwicGF5bG9hZCI6eyJ0cmFuc2FjdGlvbl9pZCI6InR4LTAwMDQiLCJhbW91bnQiOiI1LjAwIiwiY3VycmVuY3kiOiJFVVIifX0", + "noncanonical": true, + "hashes": { + "sha-256": "FixtHQ2dvNoqD3hsfrtSiMNfKPByvNhhHgTbC8lbOV8", + "sha-384": "qSbEEkr8j4qX7WhmTe6Lu2M0BzDC8sbeQyIuXkibs8jUywMith-dSvxax5x8huTG", + "sha-512": "wf-V7OgfhoulAcCWmFf71INSA-9eXalpeVzci_lrIW3EVDqLLxVcH33CnPsmRtBVS9hZHswFFukI8Zm5RBcPHw" + } + }, + { + "name": "hash_alg_string", + "note": "HASH-ONLY (the payload does not satisfy the TS12 payment schema: amount is a string, payee id/name may be missing). some verifiers send a bare string; tolerated on input", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"transaction_data_hashes_alg\":\"sha-384\",\"payload\":{\"transaction_id\":\"tx-0005\",\"amount\":\"5.00\",\"currency\":\"EUR\"}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOiJzaGEtMzg0IiwicGF5bG9hZCI6eyJ0cmFuc2FjdGlvbl9pZCI6InR4LTAwMDUiLCJhbW91bnQiOiI1LjAwIiwiY3VycmVuY3kiOiJFVVIifX0", + "noncanonical": true, + "hashes": { + "sha-256": "Rj1ByIgYl07MgeHNvdpDoTWrrCoIzvcIw1fIxMYLp7Q", + "sha-384": "9ydbtzMSREku2k6fbjEAx3n3DUatc2lPZfZHsE6BTkGp3vTMIgCoaOAUP_S8X-z9", + "sha-512": "mioiBJwqWvrHO8JHD8J2ARAExw9fp0K3a3wurTNNuTTlhidnn-XMTbAbUsbhlWayzRyQs3YUSXr-m7MqF7FXPQ" + } + }, + { + "name": "conforming_compact", + "note": "Satisfies the TS12 payment schema (amount is a number). Compact, members in spec order, so a re-serializer that sorts keys differs.", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"payload\":{\"transaction_id\":\"tx-0001\",\"payee\":{\"name\":\"Shop AB\",\"id\":\"SE1234567890\"},\"currency\":\"EUR\",\"amount\":49.99}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJwYXlsb2FkIjp7InRyYW5zYWN0aW9uX2lkIjoidHgtMDAwMSIsInBheWVlIjp7Im5hbWUiOiJTaG9wIEFCIiwiaWQiOiJTRTEyMzQ1Njc4OTAifSwiY3VycmVuY3kiOiJFVVIiLCJhbW91bnQiOjQ5Ljk5fX0", + "noncanonical": true, + "hashes": { + "sha-256": "_qWdUyyd3BH8uO2DIUJmiGZBEnjv7pGIEKwS0q7Y7Qs", + "sha-384": "wIlswFwVR-JIKinCJ_wtmDJkah7Ds4Jrvu_LwLsud40cpdV44tFj4zpEZ6t3Z5P5", + "sha-512": "dvHV5VBufzYAN0yA0NK55B6BCpBOB_7kSNKo57dlNQFLqCmspkA0-xlJcRre71G-IWuuEKiGb4AtWXQgKdiU1w" + } + }, + { + "name": "conforming_pretty_printed", + "note": "Satisfies the schema; indented with newlines.", + "json": "{\n \"type\": \"urn:eudi:sca:payment:1\",\n \"credential_ids\": [\n \"pay\"\n ],\n \"payload\": {\n \"transaction_id\": \"tx-0002\",\n \"payee\": {\n \"name\": \"Shop AB\",\n \"id\": \"SE1234567890\"\n },\n \"currency\": \"EUR\",\n \"amount\": 10.5\n }\n}", + "raw": "ewogICJ0eXBlIjogInVybjpldWRpOnNjYTpwYXltZW50OjEiLAogICJjcmVkZW50aWFsX2lkcyI6IFsKICAgICJwYXkiCiAgXSwKICAicGF5bG9hZCI6IHsKICAgICJ0cmFuc2FjdGlvbl9pZCI6ICJ0eC0wMDAyIiwKICAgICJwYXllZSI6IHsKICAgICAgIm5hbWUiOiAiU2hvcCBBQiIsCiAgICAgICJpZCI6ICJTRTEyMzQ1Njc4OTAiCiAgICB9LAogICAgImN1cnJlbmN5IjogIkVVUiIsCiAgICAiYW1vdW50IjogMTAuNQogIH0KfQ", + "noncanonical": true, + "hashes": { + "sha-256": "5O_Bubnqc8kkoXadtnPQl-Z4TXiJtB2sdQPJHw3Q_Lc", + "sha-384": "erCHyIrRed2GIcpmD9cr-wloPC6UkzWRxU9eDh2T11BN2zJDuiT3nNW_swEEgMeZ", + "sha-512": "aJ4C-AV4BlYbn2tZqmWBfDCwRwKq3VS5YBFGacPIEx4gcqSgOcXZcOR1eEKaGz66SISLaZCsFKBa7t2metk5vw" + } + }, + { + "name": "conforming_non_canonical_number", + "note": "Satisfies the schema; the amount is written 49.990, which any re-serializer prints as 49.99, so the hash differs.", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"payload\":{\"transaction_id\":\"tx-0003\",\"payee\":{\"name\":\"Shop AB\",\"id\":\"SE1234567890\"},\"currency\":\"EUR\",\"amount\":49.990}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJwYXlsb2FkIjp7InRyYW5zYWN0aW9uX2lkIjoidHgtMDAwMyIsInBheWVlIjp7Im5hbWUiOiJTaG9wIEFCIiwiaWQiOiJTRTEyMzQ1Njc4OTAifSwiY3VycmVuY3kiOiJFVVIiLCJhbW91bnQiOjQ5Ljk5MH19", + "noncanonical": true, + "hashes": { + "sha-256": "uLOIQFDBCJXJu-ZB6JaiKXryLCkJYn2-fIfFB-D8hjo", + "sha-384": "MUMKokbmW1YsA1y5Z9ocfUCR-6fDF7RH4l4pdgSPBZUaMdwmbNwAAmvGU1P7pqs-", + "sha-512": "IPNLj5Bc4E1IQwONGUMGRdrpbZ8l2S1_Do__Cw8EMc9yyO5WLNUiOgkm-OY9IOOSBJQ3xaGJ2Zog6IPxLXzKag" + } + }, + { + "name": "conforming_escapes", + "note": "Satisfies the schema; uses \\uXXXX and \\/ escapes.", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"payload\":{\"transaction_id\":\"tx-0004\",\"payee\":{\"name\":\"\\u00c5kesson \\/ Son\",\"id\":\"SE0000000001\"},\"currency\":\"SEK\",\"amount\":120}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJwYXlsb2FkIjp7InRyYW5zYWN0aW9uX2lkIjoidHgtMDAwNCIsInBheWVlIjp7Im5hbWUiOiJcdTAwYzVrZXNzb24gXC8gU29uIiwiaWQiOiJTRTAwMDAwMDAwMDEifSwiY3VycmVuY3kiOiJTRUsiLCJhbW91bnQiOjEyMH19", + "noncanonical": true, + "hashes": { + "sha-256": "OJxDFlJbqD60-2wmxla56PiFF49CpJUnVjB8r6s3KOE", + "sha-384": "8WGtWo7cYdWLbPcTtKQmAXIKnN4jCPbo6EXKbZHfXt7cdyBiKuVlCSzZScXpfWRy", + "sha-512": "SERuRonzXFXQKtGo2vTeL_G39-_czLhRXEclabvNoYYupN5QSThEllkoFToDOz4O-PFTtVQcsKVrOkglUgct4g" + } + }, + { + "name": "conforming_hash_alg_array_with_optionals", + "note": "Satisfies the schema incl. optional members (pisp, date_time, execution_date as date-time) and lists acceptable hash algorithms (request member, an array).", + "json": "{\"type\":\"urn:eudi:sca:payment:1\",\"credential_ids\":[\"pay\"],\"transaction_data_hashes_alg\":[\"sha-256\",\"sha-384\"],\"payload\":{\"transaction_id\":\"tx-0005\",\"date_time\":\"2026-10-06T14:30:00+00:00\",\"payee\":{\"name\":\"Shop AB\",\"id\":\"SE1234567890\",\"website\":\"https://shop.example.com\"},\"pisp\":{\"legal_name\":\"Pay Initiation AB\",\"brand_name\":\"PayInit\",\"domain_name\":\"payinit.example.com\"},\"execution_date\":\"2026-10-07T00:00:00+00:00\",\"currency\":\"EUR\",\"amount\":5,\"amount_estimated\":false,\"sct_inst\":true}}", + "raw": "eyJ0eXBlIjoidXJuOmV1ZGk6c2NhOnBheW1lbnQ6MSIsImNyZWRlbnRpYWxfaWRzIjpbInBheSJdLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiIsInNoYS0zODQiXSwicGF5bG9hZCI6eyJ0cmFuc2FjdGlvbl9pZCI6InR4LTAwMDUiLCJkYXRlX3RpbWUiOiIyMDI2LTEwLTA2VDE0OjMwOjAwKzAwOjAwIiwicGF5ZWUiOnsibmFtZSI6IlNob3AgQUIiLCJpZCI6IlNFMTIzNDU2Nzg5MCIsIndlYnNpdGUiOiJodHRwczovL3Nob3AuZXhhbXBsZS5jb20ifSwicGlzcCI6eyJsZWdhbF9uYW1lIjoiUGF5IEluaXRpYXRpb24gQUIiLCJicmFuZF9uYW1lIjoiUGF5SW5pdCIsImRvbWFpbl9uYW1lIjoicGF5aW5pdC5leGFtcGxlLmNvbSJ9LCJleGVjdXRpb25fZGF0ZSI6IjIwMjYtMTAtMDdUMDA6MDA6MDArMDA6MDAiLCJjdXJyZW5jeSI6IkVVUiIsImFtb3VudCI6NSwiYW1vdW50X2VzdGltYXRlZCI6ZmFsc2UsInNjdF9pbnN0Ijp0cnVlfX0", + "noncanonical": true, + "hashes": { + "sha-256": "LosqUkKKE5QeST4NaRg9i-xCxgjF4lRpSmyAe5MqHrc", + "sha-384": "X30kONlhJCIaQzwmslvAmDGaDUHCtgTo_jS207PNSaZldGzvjDKGHPpHXJRjEU-Y", + "sha-512": "dMp6iQXXc3QP1ww78fe5LPbJB0V03SvvpSATCcfNP3q5YiGVMOx_TjA_WhCpd7M_Kf16bc_mVewkiW9EA7ZSDQ" + } + } + ] +} diff --git a/pkg/openid4vp/ts12.go b/pkg/openid4vp/ts12.go new file mode 100644 index 000000000..7f3a4424d --- /dev/null +++ b/pkg/openid4vp/ts12.go @@ -0,0 +1,123 @@ +package openid4vp + +import ( + "fmt" + "regexp" + "strings" +) + +// EC TS12 v1.0.1 ("Specification of Strong Customer Authentication (SCA) +// Implementation with the Wallet") transaction data support: request side. + +const ( + // TS12TypePayment is the built-in payment transaction data type (TS12 section 4.3.1). + TS12TypePayment = "urn:eudi:sca:payment:1" + + // TransactionDataHashAlgDefault is the algorithm OpenID4VP 1.0 Appendix B.3.3.1 mandates when a request names none, and the one every implementation MUST support. + TransactionDataHashAlgDefault = "sha-256" + + // MaxTS12TransactionIDLength is the longest payload.transaction_id the TS12 payment JSON Schema allows (minLength 1, maxLength 36 per the schema as reproduced in the WMP conformance vectors). + MaxTS12TransactionIDLength = 36 +) + +var ( + ts12CurrencyRE = regexp.MustCompile(`^[A-Z]{3}$`) + // ts12AmountRE: TS12 4.3.1 says amount is a JSON number "consisting of major currency units as the integer component and an optional fraction part". A plain non-negative decimal, no exponent, no sign. + ts12AmountRE = regexp.MustCompile(`^(0|[1-9][0-9]*)(\.[0-9]+)?$`) + ts12ZeroRE = regexp.MustCompile(`^0+(\.0+)?$`) +) + +// TS12Payee is payload.payee of the payment type. name and id are REQUIRED by the TS12 payment schema. +type TS12Payee struct { + Name string `json:"name"` + ID string `json:"id"` + Website string `json:"website,omitempty"` +} + +// TS12PaymentPayload is the payload of a urn:eudi:sca:payment:1 transaction +// data object. Only members evidenced by the TS12 payment schema are modelled; +// the schema sets additionalProperties:false, so unknown members must never be +// added here. +type TS12PaymentPayload struct { + TransactionID string `json:"transaction_id"` + Payee TS12Payee `json:"payee"` + Currency string `json:"currency"` + // Amount is a JSON number. It is carried as the decimal text the operator gave so that it is neither rounded nor rewritten; it is marshalled as a bare number. + Amount RawNumber `json:"amount"` +} + +// RawNumber is a JSON number kept as text. +type RawNumber string + +// MarshalJSON emits the text as a bare JSON number. The text is validated by +// TS12PaymentPayload.Validate before it can reach a request. +func (n RawNumber) MarshalJSON() ([]byte, error) { + if !ts12AmountRE.MatchString(string(n)) { + return nil, fmt.Errorf("amount %q is not a plain decimal number", string(n)) + } + return []byte(n), nil +} + +// UnmarshalJSON keeps the number's literal text (49.990 stays 49.990). +func (n *RawNumber) UnmarshalJSON(b []byte) error { + if len(b) == 0 || b[0] == '"' || b[0] == 'n' { + return fmt.Errorf("amount must be a JSON number") + } + *n = RawNumber(b) + return nil +} + +// Validate applies the TS12 payment schema constraints this verifier can check, failing closed. +func (p *TS12PaymentPayload) Validate() error { + if l := len(p.TransactionID); l < 1 || l > MaxTS12TransactionIDLength { + return fmt.Errorf("transaction_id must be 1..%d characters", MaxTS12TransactionIDLength) + } + if strings.TrimSpace(p.Payee.Name) == "" { + return fmt.Errorf("payee.name is required") + } + if strings.TrimSpace(p.Payee.ID) == "" { + return fmt.Errorf("payee.id is required") + } + if !ts12CurrencyRE.MatchString(p.Currency) { + return fmt.Errorf("currency must be an ISO 4217 alpha-3 code") + } + if !ts12AmountRE.MatchString(string(p.Amount)) { + return fmt.Errorf("amount must be a plain decimal number") + } + if ts12ZeroRE.MatchString(string(p.Amount)) { + return fmt.Errorf("amount must be greater than zero") + } + return nil +} + +// NewTS12PaymentTransactionData builds a TS12 payment transaction data object +// bound to the given DCQL credential query ids. hashAlgs defaults to +// ["sha-256"]: TS12 4.2 makes transaction_data_hashes_alg REQUIRED, so it is +// always sent. Unsupported algorithms are refused. +func NewTS12PaymentTransactionData(payload TS12PaymentPayload, credentialIDs []string, hashAlgs []string) (*TransactionData, error) { + if err := payload.Validate(); err != nil { + return nil, fmt.Errorf("invalid payment payload: %w", err) + } + if len(credentialIDs) == 0 { + return nil, fmt.Errorf("credential_ids must not be empty") + } + for _, id := range credentialIDs { + if id == "" { + return nil, fmt.Errorf("credential_ids must not contain an empty id") + } + } + if len(hashAlgs) == 0 { + hashAlgs = []string{TransactionDataHashAlgDefault} + } + for _, a := range hashAlgs { + if _, err := newHash(a); err != nil { + return nil, err + } + } + return &TransactionData{ + Type: TS12TypePayment, + CredentialIDS: credentialIDs, + TransactionDataHashesAlg: hashAlgs, + Payload: payload, + }, nil +} diff --git a/pkg/openid4vp/ts12_test.go b/pkg/openid4vp/ts12_test.go new file mode 100644 index 000000000..e80268493 --- /dev/null +++ b/pkg/openid4vp/ts12_test.go @@ -0,0 +1,137 @@ +package openid4vp + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The vectors are copied from sirosfoundation WMP spec repo +// (leifj/wmp), vectors/openid4x/transaction-data-hashes.json at main +// 952237a (conforming_* vectors added in 2c67902). Hashes there were computed +// with Python hashlib, independent of any wallet or verifier. +type ts12Vector struct { + Name string `json:"name"` + Note string `json:"note"` + JSON string `json:"json"` + Raw string `json:"raw"` + Noncanonical bool `json:"noncanonical"` + Hashes map[string]string `json:"hashes"` +} + +func loadTS12Vectors(t *testing.T) []ts12Vector { + t.Helper() + b, err := os.ReadFile("testdata/ts12/transaction-data-hashes.json") + require.NoError(t, err) + var f struct { + Vectors []ts12Vector `json:"vectors"` + } + require.NoError(t, json.Unmarshal(b, &f)) + require.NotEmpty(t, f.Vectors) + return f.Vectors +} + +func TestHashTransactionData_WMPGoldenVectors(t *testing.T) { + for _, v := range loadTS12Vectors(t) { + for alg, want := range v.Hashes { + t.Run(v.Name+"/"+alg, func(t *testing.T) { + got, err := HashTransactionData([]string{v.Raw}, alg) + require.NoError(t, err) + assert.Equal(t, []string{want}, got, "hash is over the raw base64url string as received") + + // The raw string must really be the base64url of the documented JSON, or the vector is not what it says. + dec, err := base64.RawURLEncoding.DecodeString(v.Raw) + require.NoError(t, err) + assert.Equal(t, v.JSON, string(dec)) + }) + } + } +} + +// Conforming vectors are what a spec-exact verifier could send: their payload +// must be accepted by our own payload type, with no member we do not model. +func TestTS12PaymentPayload_ConformingVectors(t *testing.T) { + n := 0 + for _, v := range loadTS12Vectors(t) { + if len(v.Name) < 10 || v.Name[:10] != "conforming" { + continue + } + var td struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + require.NoError(t, json.Unmarshal([]byte(v.JSON), &td)) + assert.Equal(t, TS12TypePayment, td.Type) + + dec := json.NewDecoder(bytes.NewReader(td.Payload)) + dec.DisallowUnknownFields() + var p TS12PaymentPayload + if err := dec.Decode(&p); err != nil { + // Optional members this verifier does not send (pisp, date_time, ...) are fine to receive. + require.Contains(t, v.Name, "optionals", v.Name) + continue + } + require.NoError(t, p.Validate(), v.Name) + n++ + } + assert.GreaterOrEqual(t, n, 4) +} + +func TestTS12PaymentPayload_AmountKeepsItsText(t *testing.T) { + var p TS12PaymentPayload + require.NoError(t, json.Unmarshal([]byte(`{"transaction_id":"t","payee":{"name":"n","id":"i"},"currency":"EUR","amount":49.990}`), &p)) + out, err := json.Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(out), `"amount":49.990}`) +} + +func TestNewTS12PaymentTransactionData(t *testing.T) { + good := TS12PaymentPayload{TransactionID: "tx-1", Payee: TS12Payee{Name: "Shop AB", ID: "SE1"}, Currency: "EUR", Amount: "49.99"} + + td, err := NewTS12PaymentTransactionData(good, []string{"sca"}, nil) + require.NoError(t, err) + enc, err := td.Base64Encode() + require.NoError(t, err) + dec, err := base64.RawURLEncoding.DecodeString(enc) + require.NoError(t, err) + + var m map[string]any + require.NoError(t, json.Unmarshal(dec, &m)) + assert.Equal(t, "urn:eudi:sca:payment:1", m["type"]) + assert.Equal(t, []any{"sca"}, m["credential_ids"]) + assert.Equal(t, []any{"sha-256"}, m["transaction_data_hashes_alg"], "TS12 4.2: REQUIRED, and an array in the request") + assert.Contains(t, string(dec), `"amount":49.99`, "amount is a JSON number, not a string") + + bad := map[string]func(p *TS12PaymentPayload){ + "empty tx id": func(p *TS12PaymentPayload) { p.TransactionID = "" }, + "tx id too long": func(p *TS12PaymentPayload) { p.TransactionID = string(make([]byte, 37)) }, + "no payee name": func(p *TS12PaymentPayload) { p.Payee.Name = " " }, + "no payee id": func(p *TS12PaymentPayload) { p.Payee.ID = "" }, + "lowercase ccy": func(p *TS12PaymentPayload) { p.Currency = "eur" }, + "long ccy": func(p *TS12PaymentPayload) { p.Currency = "EURO" }, + "negative amount": func(p *TS12PaymentPayload) { p.Amount = "-1" }, + "zero amount": func(p *TS12PaymentPayload) { p.Amount = "0.00" }, + "exponent amount": func(p *TS12PaymentPayload) { p.Amount = "1e3" }, + "injection amount": func(p *TS12PaymentPayload) { p.Amount = "1,\"x\":2" }, + "empty amount": func(p *TS12PaymentPayload) { p.Amount = "" }, + "leading zero": func(p *TS12PaymentPayload) { p.Amount = "01.5" }, + } + for name, mut := range bad { + t.Run(name, func(t *testing.T) { + p := good + mut(&p) + _, err := NewTS12PaymentTransactionData(p, []string{"sca"}, nil) + assert.Error(t, err) + }) + } + + _, err = NewTS12PaymentTransactionData(good, nil, nil) + assert.Error(t, err, "credential_ids required") + _, err = NewTS12PaymentTransactionData(good, []string{"sca"}, []string{"md5"}) + assert.Error(t, err, "unsupported alg refused") +} From a668e1444566dbd390c5f72bf3d05878e426f207 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:18:54 +0200 Subject: [PATCH 02/20] feat(verifier): validate EC TS12 SCA responses (hashes, amr, jti, response_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 --- internal/verifier/apiv1/handler_ts12.go | 35 ++ .../apiv1/handler_ts12_response_test.go | 190 +++++++++++ .../verifier/apiv1/handlers_verification.go | 39 +++ .../apiv1/handlers_verification_test.go | 10 + internal/verifier/apiv1/mock_db_test.go | 1 + internal/verifier/cache/service.go | 9 + pkg/openid4vp/ts12_response.go | 238 ++++++++++++++ pkg/openid4vp/ts12_response_test.go | 301 ++++++++++++++++++ pkg/openid4vp/validator.go | 102 ++++-- 9 files changed, 890 insertions(+), 35 deletions(-) create mode 100644 internal/verifier/apiv1/handler_ts12_response_test.go create mode 100644 pkg/openid4vp/ts12_response.go create mode 100644 pkg/openid4vp/ts12_response_test.go diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go index 3e7d1159d..0c2d101dd 100644 --- a/internal/verifier/apiv1/handler_ts12.go +++ b/internal/verifier/apiv1/handler_ts12.go @@ -1,6 +1,7 @@ package apiv1 import ( + "context" "encoding/json" "fmt" "net/http" @@ -91,3 +92,37 @@ func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4v } return []string{encoded}, nil } + +// issuedResponseModes are the response_mode values the verifier puts in the +// requests it issues for /ui/interaction: direct_post.jwt behind the QR code +// and link, and dc_api.jwt for the Digital Credentials API variant when that +// is enabled. The response arrives at one endpoint whichever was used, so a +// TS12 KB-JWT may echo either. +func (c *Client) issuedResponseModes() []string { + modes := []string{openid4vp.ResponseModeDirectPostJWT} + if c.cfg.Verifier.DigitalCredentials.Enable { + modes = append(modes, openid4vp.ResponseModeDCAPIJWT) + } + return modes +} + +// jtiStore adapts the verifier's replay cache to the validator. With no cache +// the store is nil, which makes the validator refuse an SCA presentation. +func (c *Client) jtiStore(ctx context.Context) openid4vp.JTIStore { + if c.cacheService == nil || c.cacheService.UsedJTI == nil { + return nil + } + return jtiClaimer{ctx: ctx, cache: c.cacheService.UsedJTI} +} + +type jtiClaimer struct { + ctx context.Context + cache interface { + SetNX(ctx context.Context, key string, value bool) (bool, error) + } +} + +func (j jtiClaimer) Claim(jti string) (bool, error) { + // Atomic: a concurrent second submission of the same KB-JWT loses SetNX. + return j.cache.SetNX(j.ctx, jti, true) +} diff --git a/internal/verifier/apiv1/handler_ts12_response_test.go b/internal/verifier/apiv1/handler_ts12_response_test.go new file mode 100644 index 000000000..85f82ab43 --- /dev/null +++ b/internal/verifier/apiv1/handler_ts12_response_test.go @@ -0,0 +1,190 @@ +package apiv1 + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "testing" + + "github.com/SUNET/vc/internal/verifier/notify" + "github.com/SUNET/vc/pkg/jose" + "github.com/SUNET/vc/pkg/logger" + "github.com/SUNET/vc/pkg/openid4vp" + "github.com/SUNET/vc/pkg/trust" + + "github.com/lestrrat-go/jwx/v3/jwa" + "github.com/lestrrat-go/jwx/v3/jwe" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ts12Rig is a verifier with a payment session open and a wallet stand-in +// that answers it the way the Kotlin/Swift SDKs do. +type ts12Rig struct { + t *testing.T + client *Client + key *ecdsa.PrivateKey + sessID string +} + +func newTS12Rig(t *testing.T) *ts12Rig { + t.Helper() + return newTS12RigFor(t, scaDCQL("sca"), testPayment()) +} + +func newTS12RigFor(t *testing.T, dcql *openid4vp.DCQL, payment *UIPaymentRequest) *ts12Rig { + t.Helper() + ctx := t.Context() + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + log := logger.NewSimple("test") + notifyService, _ := notify.New(ctx, client.cfg, log) + client.notify = notifyService + openid4vpClient, _ := openid4vp.New(ctx, &openid4vp.Config{}) + client.openid4vp = openid4vpClient + client.jwtTrustVerifier = trust.NewJWTTrustVerifier(trust.JWTTrustVerifierConfig{ + TrustEvaluator: trust.NewAllowAllEvaluator(), + JWKSResolver: trust.NewJWKSKeyResolver(trust.JWKSResolverConfig{}), + ParseX5C: func(x5cRaw any) ([]*x509.Certificate, error) { return jose.ParseX5CHeader(x5cRaw) }, + ParseJWK: jose.ParseJWKToPublicKey, + Log: log, + }) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + reply, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: dcql, Payment: payment}) + require.NoError(t, err) + return &ts12Rig{t: t, client: client, key: key, sessID: reply.SessionID} +} + +var scaGoodAMR = []any{ + map[string]any{"knowledge": "pin_6_or_more_digits"}, + map[string]any{"possession": "key_in_local_external_wscd"}, +} + +// kbClaims is what a conformant wallet adds for this session; mutate to break it. +func (r *ts12Rig) kbClaims(jti string) map[string]any { + authCtx, err := r.client.cacheService.AuthContext.GetByID(r.t.Context(), r.sessID) + require.NoError(r.t, err) + hashes, err := openid4vp.HashTransactionData(authCtx.TransactionData, "sha-256") + require.NoError(r.t, err) + return map[string]any{ + "transaction_data_hashes": hashes, + "transaction_data_hashes_alg": "sha-256", + "jti": jti, + "response_mode": "direct_post.jwt", + "amr": scaGoodAMR, + } +} + +func (r *ts12Rig) post(kb map[string]any) error { + r.t.Helper() + ctx := r.t.Context() + authCtx, err := r.client.cacheService.AuthContext.GetByID(ctx, r.sessID) + require.NoError(r.t, err) + tok := createTestSDJWTWithKBClaims(r.t, r.key, map[string]any{"given_name": "Ada"}, nil, authCtx.Nonce, authCtx.ClientID, kb) + body, err := json.Marshal(openid4vp.VPResponse{VPToken: map[string][]string{"sca": {tok}}, State: authCtx.State}) + require.NoError(r.t, err) + _, pub, err := r.client.ephemeralEncryptionKey(ctx, authCtx.EphemeralEncryptionKeyID) + require.NoError(r.t, err) + enc, err := jwe.Encrypt(body, jwe.WithKey(jwa.ECDH_ES(), pub), jwe.WithContentEncryption(jwa.A256GCM())) + require.NoError(r.t, err) + _, err = r.client.VerificationDirectPost(ctx, &VerificationDirectPostRequest{Response: string(enc)}) + return err +} + +func TestVerificationDirectPost_TS12Payment(t *testing.T) { + t.Run("a conformant presentation is accepted", func(t *testing.T) { + r := newTS12Rig(t) + require.NoError(t, r.post(r.kbClaims("jti-ok-1"))) + }) + + refused := map[string]func(kb map[string]any){ + "no transaction_data_hashes": func(kb map[string]any) { + delete(kb, "transaction_data_hashes") + delete(kb, "transaction_data_hashes_alg") + }, + "hash of something else": func(kb map[string]any) { + kb["transaction_data_hashes"] = []string{"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"} + }, + "alg as array (wallet-common)": func(kb map[string]any) { kb["transaction_data_hashes_alg"] = []string{"sha-256"} }, + "single-factor RFC 8176 amr": func(kb map[string]any) { kb["amr"] = []string{"hwk", "pop"} }, + "one category only": func(kb map[string]any) { kb["amr"] = []any{map[string]any{"possession": "key_in_remote_wscd"}} }, + "no jti": func(kb map[string]any) { delete(kb, "jti") }, + "wrong response_mode": func(kb map[string]any) { kb["response_mode"] = "direct_post" }, + "no response_mode": func(kb map[string]any) { delete(kb, "response_mode") }, + } + for name, mutate := range refused { + t.Run("refuses "+name, func(t *testing.T) { + r := newTS12Rig(t) + kb := r.kbClaims("jti-" + name) + mutate(kb) + err := r.post(kb) + require.Error(t, err) + assert.Contains(t, err.Error(), openid4vp.ErrorInvalidTransactionData) + }) + } + + t.Run("a plain presentation of a payment request is refused", func(t *testing.T) { + r := newTS12Rig(t) + err := r.post(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), openid4vp.ErrorInvalidTransactionData) + }) + + t.Run("the same jti is refused in a second session", func(t *testing.T) { + first := newTS12Rig(t) + require.NoError(t, first.post(first.kbClaims("replayed-jti"))) + second := newTS12Rig(t) + second.client.cacheService.UsedJTI = first.client.cacheService.UsedJTI + err := second.post(second.kbClaims("replayed-jti")) + require.Error(t, err) + assert.Contains(t, err.Error(), "jti has been used before") + }) + + t.Run("no replay store refuses", func(t *testing.T) { + r := newTS12Rig(t) + r.client.cacheService.UsedJTI = nil + err := r.post(r.kbClaims("jti-nostore")) + require.Error(t, err) + assert.Contains(t, err.Error(), "no replay store") + }) +} + +// A payment bound to one credential of two: the other credential's +// presentation owes no hashes, and the bound one still does. +func TestVerificationDirectPost_TS12ScopedToTheBoundCredential(t *testing.T) { + dcql := scaDCQL("sca") + dcql.Credentials = append(dcql.Credentials, openid4vp.CredentialQuery{ + ID: "age", Format: openid4vp.FormatSDJWTVC, Meta: openid4vp.MetaQuery{VCTValues: []string{"https://example.com/age"}}, + }) + p := testPayment() + p.CredentialID = "sca" + + answer := func(r *ts12Rig, scaKB map[string]any) error { + ctx := r.t.Context() + authCtx, err := r.client.cacheService.AuthContext.GetByID(ctx, r.sessID) + require.NoError(r.t, err) + mk := func(kb map[string]any) string { + return createTestSDJWTWithKBClaims(r.t, r.key, map[string]any{"given_name": "Ada"}, nil, authCtx.Nonce, authCtx.ClientID, kb) + } + body, err := json.Marshal(openid4vp.VPResponse{VPToken: map[string][]string{"sca": {mk(scaKB)}, "age": {mk(nil)}}, State: authCtx.State}) + require.NoError(r.t, err) + _, pub, err := r.client.ephemeralEncryptionKey(ctx, authCtx.EphemeralEncryptionKeyID) + require.NoError(r.t, err) + enc, err := jwe.Encrypt(body, jwe.WithKey(jwa.ECDH_ES(), pub), jwe.WithContentEncryption(jwa.A256GCM())) + require.NoError(r.t, err) + _, err = r.client.VerificationDirectPost(ctx, &VerificationDirectPostRequest{Response: string(enc)}) + return err + } + + r := newTS12RigFor(t, dcql, p) + require.NoError(t, answer(r, r.kbClaims("scoped-ok"))) + + r = newTS12RigFor(t, dcql, p) + err := answer(r, nil) + require.Error(t, err, "the bound credential must carry the hashes even if the other one does not") + assert.Contains(t, err.Error(), openid4vp.ErrorInvalidTransactionData) +} diff --git a/internal/verifier/apiv1/handlers_verification.go b/internal/verifier/apiv1/handlers_verification.go index d68035b9b..efa7beea0 100644 --- a/internal/verifier/apiv1/handlers_verification.go +++ b/internal/verifier/apiv1/handlers_verification.go @@ -224,6 +224,7 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi defaultAllowed := c.defaultTokenAllowed(authCtx, scopeQueryIDs, credentialScopes) scopeCredentials := make(map[string][]sdjwtvc.CredentialCache, len(credentialScopes)) + var answeredTransactionQueries []string for _, scope := range credentialScopes { vpTokens, err := c.vpTokensForScope(authCtx, scopeQueryIDs, defaultAllowed, vpResponse, scope) @@ -293,6 +294,27 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi } } + // transaction_data: the entries bound to this credential query must be + // answered by an SD-JWT VC (EC TS12 v1.0.1 covers nothing else, and + // OpenID4VP's transaction_data_hashes is defined only for it here). + // Any other format could not have committed to the transaction, so + // it is refused rather than accepted without the check. + scopeQueryID := queryIDForScopeIn(scopeQueryIDs, scope) + if len(authCtx.TransactionData) > 0 { + bound, err := openid4vp.TransactionDataForCredential(authCtx.TransactionData, scopeQueryID) + if err != nil { + c.log.Error(err, "session transaction_data is unreadable", "scope", scope) + return nil, &openid4vp.ErrorResponse{ErrorCode: openid4vp.ErrorInvalidTransactionData, ErrorDescription: "session transaction_data is unreadable"} + } + if len(bound) > 0 { + if format != FormatSDJWT { + c.log.Error(nil, "transaction_data answered by a format that cannot carry it", "scope", scope, "format", format) + return nil, &openid4vp.ErrorResponse{ErrorCode: openid4vp.ErrorInvalidTransactionData, ErrorDescription: fmt.Sprintf("transaction_data requires an SD-JWT VC presentation for %q", scopeQueryID)} + } + answeredTransactionQueries = append(answeredTransactionQueries, scopeQueryID) + } + } + switch format { case FormatSDJWT: // SD-JWT: Use VPTokenValidator for format validation, then evaluateIssuerTrust for sig+trust @@ -302,6 +324,12 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi ValidateFormat: false, // We do real signature verification in evaluateIssuerTrust CheckRevocation: false, } + if len(authCtx.TransactionData) > 0 { + validator.TransactionData = authCtx.TransactionData + validator.CredentialQueryID = scopeQueryID + validator.ResponseModes = c.issuedResponseModes() + validator.JTIStore = c.jtiStore(ctx) + } if err := validator.Validate(responseParams.VPToken); err != nil { c.log.Error(err, "VP Token validation failed", "scope", scope) @@ -791,6 +819,17 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi } } + // Every transaction_data entry must have been committed to by a credential + // that answered a query it references. An entry whose credential was not + // presented (an optional query the wallet skipped) has not been authorized. + if len(authCtx.TransactionData) > 0 { + uncovered, err := openid4vp.UncoveredTransactionData(authCtx.TransactionData, answeredTransactionQueries) + if err != nil || len(uncovered) > 0 { + c.log.Error(err, "transaction_data not covered by any presented credential", "uncovered", len(uncovered)) + return nil, &openid4vp.ErrorResponse{ErrorCode: openid4vp.ErrorInvalidTransactionData, ErrorDescription: "transaction_data not authorized by any presented credential"} + } + } + // Flatten per-scope credentials into ordered slice for caching credentialCaches := make([]sdjwtvc.CredentialCache, 0, len(authCtx.Scopes)) for _, scope := range authCtx.Scopes { diff --git a/internal/verifier/apiv1/handlers_verification_test.go b/internal/verifier/apiv1/handlers_verification_test.go index a7c4533e0..d8db73e5b 100644 --- a/internal/verifier/apiv1/handlers_verification_test.go +++ b/internal/verifier/apiv1/handlers_verification_test.go @@ -582,6 +582,13 @@ func createTestSDJWT(t *testing.T, key *ecdsa.PrivateKey, claims map[string]any, // (disclosures not referenced in the _sd array). These should be ignored during validation. func createTestSDJWTWithDecoys(t *testing.T, key *ecdsa.PrivateKey, claims map[string]any, decoys map[string]any, nonce, audience string) string { t.Helper() + return createTestSDJWTWithKBClaims(t, key, claims, decoys, nonce, audience, nil) +} + +// createTestSDJWTWithKBClaims is createTestSDJWTWithDecoys with extra claims +// merged into the Key Binding JWT (transaction_data_hashes, amr, jti, ...). +func createTestSDJWTWithKBClaims(t *testing.T, key *ecdsa.PrivateKey, claims map[string]any, decoys map[string]any, nonce, audience string, extraKB map[string]any) string { + t.Helper() // Build selective disclosures: [salt, claim_name, value] // and compute their hashes for the _sd array @@ -651,6 +658,9 @@ func createTestSDJWTWithDecoys(t *testing.T, key *ecdsa.PrivateKey, claims map[s // disclosures the sender picked. "sd_hash": base64.RawURLEncoding.EncodeToString(sum[:]), } + for k, v := range extraKB { + kbClaims[k] = v + } kbToken := jwt.NewWithClaims(jwt.SigningMethodES256, kbClaims) kbToken.Header["typ"] = "kb+jwt" signedKBJWT, err := kbToken.SignedString(key) diff --git a/internal/verifier/apiv1/mock_db_test.go b/internal/verifier/apiv1/mock_db_test.go index d3717b856..1a11133df 100644 --- a/internal/verifier/apiv1/mock_db_test.go +++ b/internal/verifier/apiv1/mock_db_test.go @@ -164,6 +164,7 @@ func CreateTestClientWithMock(t testing.TB, cfg *model.Cfg) (*Client, *MockDBSer AuthContext: cache.NewTestMemoryStore(15 * time.Minute), EphemeralEncryptionKey: cache.NewTestMemoryCache[jwk.Key](10 * time.Minute), RequestObject: cache.NewTestMemoryCache[*openid4vp.RequestObject](5 * time.Minute), + UsedJTI: cache.NewTestMemoryCache[bool](15 * time.Minute), Credential: cache.NewTestMemoryCache[[]sdjwtvc.CredentialCache](5 * time.Minute), }, jwksResolver: trust.NewJWKSKeyResolver(trust.JWKSResolverConfig{}), diff --git a/internal/verifier/cache/service.go b/internal/verifier/cache/service.go index 6c853d3c5..c1f9efa61 100644 --- a/internal/verifier/cache/service.go +++ b/internal/verifier/cache/service.go @@ -42,6 +42,9 @@ type Service struct { Credential Cache[[]sdjwtvc.CredentialCache] EphemeralEncryptionKey Cache[jwk.Key] RequestObject Cache[*openid4vp.RequestObject] + // UsedJTI records the jti of every accepted-or-attempted EC TS12 SCA + // presentation so the same KB-JWT cannot be submitted twice. + UsedJTI Cache[bool] // SessionAuthKey is the HMAC key for session cookies, shared across HA instances. SessionAuthKey string @@ -81,6 +84,12 @@ func New(ctx context.Context, cfg *model.Cfg, dbService *db.Service, tracer *tra return nil, fmt.Errorf("cache: request_objects: %w", err) } + // The nonce already ties a KB-JWT to one session, so a jti only has to be + // remembered as long as the session can still accept a response. + if s.UsedJTI, err = pkgcache.NewGenericCache[bool](cs, ctx, "verifier_used_jti", presentationScopedTTL(cfg, 15*time.Minute)); err != nil { + return nil, fmt.Errorf("cache: used_jti: %w", err) + } + // Resolve HA-shared session keys (atomic upsert in MongoDB when HA, ephemeral otherwise). sharedSecrets, err := pkgcache.EnsureSharedSecrets(ctx, cs, "verifier") if err != nil { diff --git a/pkg/openid4vp/ts12_response.go b/pkg/openid4vp/ts12_response.go new file mode 100644 index 000000000..8512ffbf4 --- /dev/null +++ b/pkg/openid4vp/ts12_response.go @@ -0,0 +1,238 @@ +package openid4vp + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "slices" + "strings" +) + +// EC TS12 v1.0.1 / OpenID4VP 1.0 Appendix B.3.3.1: response side. + +// TS12TypePrefix is the namespace of the built-in TS12 transaction data types +// (urn:eudi:sca:payment:1, login_risk_transaction:1, account_access:1, +// emandate:1). A request carrying one of them is an SCA request and its +// response is held to the TS12 KB-JWT rules (section 3.6), not just to the +// generic transaction_data_hashes rules. +const TS12TypePrefix = "urn:eudi:sca:" + +// TS12 authentication-method categories (section 3.6). +const ( + AMRKnowledge = "knowledge" + AMRPossession = "possession" + AMRInherence = "inherence" +) + +// ts12AMRVocabulary is the closed value set per category in TS12 v1.0.1 +// section 3.6. A value outside it is refused rather than passed: the claim is +// the wallet's own assertion, and an unknown value is one we cannot interpret. +var ts12AMRVocabulary = map[string][]string{ + AMRKnowledge: { + "pin_less_than_6_digits", "pin_6_or_more_digits", + "passphrase_less_than_8_chars", "passphrase_8_to_11_chars", "passphrase_12_or_more_chars", + "pattern", "other", + }, + AMRPossession: { + "key_in_remote_wscd", "key_in_local_external_wscd", "key_in_local_internal_wscd", + "key_in_local_native_wscd", "other", + }, + AMRInherence: { + "fingerprint_device", "fingerprint_external", "face_device", "face_external", "other", + }, +} + +// JTIStore records KB-JWT jti values so a presentation cannot be accepted +// twice. Claim reports whether jti was unseen, atomically recording it. An +// error means the store could not say, and the presentation is refused. +type JTIStore interface { + Claim(jti string) (fresh bool, err error) +} + +// decodedTransactionData is the part of a transaction_data entry the response +// check needs. It is read from the raw string for scoping and algorithm +// negotiation only; hashes are always computed over the raw string. +type decodedTransactionData struct { + Raw string + Type string + CredentialIDs []string + HashAlgs []string + HasAlgList bool +} + +func decodeTransactionDataEntry(raw string) (decodedTransactionData, error) { + d := decodedTransactionData{Raw: raw} + b, err := base64.RawURLEncoding.DecodeString(raw) + if err != nil { + return d, fmt.Errorf("not base64url: %w", err) + } + var m struct { + Type string `json:"type"` + CredentialIDs []string `json:"credential_ids"` + HashAlgs json.RawMessage `json:"transaction_data_hashes_alg"` + } + if err := json.Unmarshal(b, &m); err != nil { + return d, fmt.Errorf("not a JSON object: %w", err) + } + if m.Type == "" { + return d, fmt.Errorf("no type") + } + d.Type, d.CredentialIDs = m.Type, m.CredentialIDs + if len(m.HashAlgs) > 0 && string(m.HashAlgs) != "null" { + // The request member is an ARRAY (OpenID4VP 1.0 B.3.3.1). A bare + // string is what some verifiers send and the WMP vectors tolerate on + // input, but this verifier sent the entry, so anything else is a bug + // here and is refused. + if err := json.Unmarshal(m.HashAlgs, &d.HashAlgs); err != nil || len(d.HashAlgs) == 0 { + return d, fmt.Errorf("transaction_data_hashes_alg is not a non-empty array") + } + d.HasAlgList = true + } + return d, nil +} + +func decodeTransactionData(raw []string) ([]decodedTransactionData, error) { + out := make([]decodedTransactionData, 0, len(raw)) + for i, r := range raw { + d, err := decodeTransactionDataEntry(r) + if err != nil { + return nil, fmt.Errorf("transaction_data[%d]: %w", i, err) + } + out = append(out, d) + } + return out, nil +} + +// TransactionDataForCredential returns the entries (as sent, in request order) +// whose credential_ids reference the DCQL credential query id. +// +// OpenID4VP 1.0 does not say whether a credential's KB-JWT carries the hashes +// of every transaction_data entry or only of the ones that reference it, and +// TS12 is silent too (SIROS plan B8). This verifier requires exactly the +// referencing entries, in request order: it is the reading under which a +// payment bound to one credential and an unrelated entry bound to another do +// not force each wallet presentation to commit to the other's transaction. +func TransactionDataForCredential(raw []string, queryID string) ([]string, error) { + dec, err := decodeTransactionData(raw) + if err != nil { + return nil, err + } + var out []string + for _, d := range dec { + if slices.Contains(d.CredentialIDs, queryID) { + out = append(out, d.Raw) + } + } + return out, nil +} + +// UncoveredTransactionData returns the entries that no answered credential +// query references. A response that never committed to one of them has not +// authorized it, so the caller must refuse. +func UncoveredTransactionData(raw []string, answeredQueryIDs []string) ([]string, error) { + dec, err := decodeTransactionData(raw) + if err != nil { + return nil, err + } + var out []string + for _, d := range dec { + covered := false + for _, id := range d.CredentialIDs { + if slices.Contains(answeredQueryIDs, id) { + covered = true + } + } + if !covered { + out = append(out, d.Raw) + } + } + return out, nil +} + +func txError(format string, args ...any) error { + return &ErrorResponse{ErrorCode: ErrorInvalidTransactionData, ErrorDescription: fmt.Sprintf(format, args...)} +} + +// allowedHashAlgs returns the set the wallet was allowed to use for the KB-JWT: +// entries' request lists and returns the set the wallet was allowed to use: +// the intersection of the entries' lists, with an entry that sent no list +// allowing only the sha-256 default. +func allowedHashAlgs(entries []decodedTransactionData) []string { + var allowed []string + for i, e := range entries { + algs := e.HashAlgs + if !e.HasAlgList { + algs = []string{TransactionDataHashAlgDefault} + } + if i == 0 { + allowed = slices.Clone(algs) + continue + } + allowed = slices.DeleteFunc(allowed, func(a string) bool { return !slices.Contains(algs, a) }) + } + return allowed +} + +// validateTS12Claims applies EC TS12 v1.0.1 section 3.6 to the KB-JWT claims: +// jti, response_mode and amr. iat, nonce and aud are checked for every +// presentation elsewhere. +func (v *VPTokenValidator) validateTS12Claims(kb map[string]any) error { + // amr: at least two DIFFERENT categories; single-key objects; closed vocabulary. + amr, ok := kb["amr"].([]any) + if !ok || len(amr) == 0 { + return txError("amr missing from KB-JWT or not an array") + } + categories := map[string]bool{} + for i, item := range amr { + obj, ok := item.(map[string]any) + if !ok || len(obj) != 1 { + return txError("amr[%d] is not an object with exactly one member", i) + } + for category, val := range obj { + vocab, known := ts12AMRVocabulary[category] + if !known { + return txError("amr[%d]: unknown category %q", i, category) + } + s, ok := val.(string) + if !ok || !slices.Contains(vocab, s) { + return txError("amr[%d]: value is not a TS12 %s method", i, category) + } + categories[category] = true + } + } + if len(categories) < 2 { + return txError("amr names %d authentication categories, TS12 requires at least 2 different ones", len(categories)) + } + + // response_mode: the value from the Authorization Request. The verifier + // does not know which channel (QR/link or DC API) the wallet used, so the + // caller supplies every mode it issued; with none, the claim cannot be + // checked and the presentation is refused. + rm, ok := kb["response_mode"].(string) + if !ok || rm == "" { + return txError("response_mode missing from KB-JWT") + } + if len(v.ResponseModes) == 0 { + return txError("cannot verify response_mode: no issued response modes configured") + } + if !slices.Contains(v.ResponseModes, rm) { + return txError("response_mode %q is not one this request was issued with", rm) + } + + // jti: fresh, never seen. Entropy cannot be checked from here; uniqueness can. + jti, ok := kb["jti"].(string) + if !ok || strings.TrimSpace(jti) == "" { + return txError("jti missing from KB-JWT") + } + if v.JTIStore == nil { + return txError("cannot verify jti freshness: no replay store configured") + } + fresh, err := v.JTIStore.Claim(jti) + if err != nil { + return txError("cannot verify jti freshness: %v", err) + } + if !fresh { + return txError("jti has been used before") + } + return nil +} diff --git a/pkg/openid4vp/ts12_response_test.go b/pkg/openid4vp/ts12_response_test.go new file mode 100644 index 000000000..60f83f3e3 --- /dev/null +++ b/pkg/openid4vp/ts12_response_test.go @@ -0,0 +1,301 @@ +package openid4vp + +import ( + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type memJTIStore struct { + seen map[string]bool + err error +} + +func (m *memJTIStore) Claim(jti string) (bool, error) { + if m.err != nil { + return false, m.err + } + if m.seen == nil { + m.seen = map[string]bool{} + } + if m.seen[jti] { + return false, nil + } + m.seen[jti] = true + return true, nil +} + +func vectorByName(t *testing.T, name string) ts12Vector { + t.Helper() + for _, v := range loadTS12Vectors(t) { + if v.Name == name { + return v + } + } + t.Fatalf("vector %q not found", name) + return ts12Vector{} +} + +var goodAMR = []any{ + map[string]any{"knowledge": "pin_6_or_more_digits"}, + map[string]any{"possession": "key_in_local_external_wscd"}, +} + +// ts12Case is one KB-JWT against one request. +type ts12Case struct { + entries []string + queryID string + store JTIStore + modes []string + claims map[string]any // merged over a good base; a nil value deletes the claim +} + +func (c ts12Case) validate(t *testing.T) error { + t.Helper() + holder := testHolderKey(t) + kb := map[string]any{ + "nonce": "n-1", + "aud": "x509_hash:verifier", + "iat": time.Now().Unix(), + "jti": "jti-" + time.Now().Format(time.RFC3339Nano), + "response_mode": "direct_post.jwt", + "amr": goodAMR, + } + for k, v := range c.claims { + if v == nil { + delete(kb, k) + } else { + kb[k] = v + } + } + tok := testPresentation(t, testCredentialNaming(t, holder, nil), holder, kb) + store := c.store + if store == nil { + store = &memJTIStore{} + } + modes := c.modes + if modes == nil { + modes = []string{"direct_post.jwt", "dc_api.jwt"} + } + v := &VPTokenValidator{ + Nonce: "n-1", ClientID: "x509_hash:verifier", + TransactionData: c.entries, CredentialQueryID: c.queryID, + ResponseModes: modes, JTIStore: store, + } + return v.Validate(tok) +} + +func requireTxError(t *testing.T, err error, contains string) { + t.Helper() + require.Error(t, err) + var er *ErrorResponse + require.True(t, errors.As(err, &er), "want an ErrorResponse, got %v", err) + assert.Equal(t, ErrorInvalidTransactionData, er.ErrorCode) + assert.Contains(t, er.ErrorDescription, contains) +} + +// The vector's wallet-side hashes (computed with Python, over the raw string) +// are accepted for a non-canonical entry, and the one a re-serializing wallet +// would produce is not. +func TestTS12Response_GoldenVectorHashes(t *testing.T) { + for _, name := range []string{"conforming_compact", "conforming_pretty_printed", "conforming_non_canonical_number", "conforming_escapes"} { + v := vectorByName(t, name) + t.Run(name, func(t *testing.T) { + ok := ts12Case{entries: []string{v.Raw}, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": []string{v.Hashes["sha-256"]}, + }} + require.NoError(t, ok.validate(t)) + + // What a wallet that decodes and re-serializes the entry would send. + var generic map[string]any + require.NoError(t, json.Unmarshal([]byte(v.JSON), &generic)) + re, err := json.Marshal(generic) + require.NoError(t, err) + reHash, err := HashTransactionData([]string{base64.RawURLEncoding.EncodeToString(re)}, "sha-256") + require.NoError(t, err) + require.NotEqual(t, v.Hashes["sha-256"], reHash[0], "vector must be non-canonical for this test to mean anything") + bad := ts12Case{entries: []string{v.Raw}, queryID: "pay", claims: map[string]any{"transaction_data_hashes": reHash}} + requireTxError(t, bad.validate(t), "mismatch") + }) + } +} + +func TestTS12Response_HashAlgNegotiation(t *testing.T) { + v := vectorByName(t, "conforming_hash_alg_array_with_optionals") // request lists sha-256, sha-384 + entries := []string{v.Raw} + with := func(alg any, hash string) ts12Case { + c := map[string]any{"transaction_data_hashes": []string{hash}} + c["transaction_data_hashes_alg"] = alg + return ts12Case{entries: entries, queryID: "pay", claims: c} + } + require.NoError(t, with("sha-256", v.Hashes["sha-256"]).validate(t)) + require.NoError(t, with("sha-384", v.Hashes["sha-384"]).validate(t)) + requireTxError(t, with("sha-512", v.Hashes["sha-512"]).validate(t), "not one the request allowed") + requireTxError(t, with([]string{"sha-256"}, v.Hashes["sha-256"]).validate(t), "must be a string") + requireTxError(t, with(nil, v.Hashes["sha-256"]).validate(t), "transaction_data_hashes_alg missing") + // Right alg named, hash of another alg. + requireTxError(t, with("sha-384", v.Hashes["sha-256"]).validate(t), "mismatch") + + // No list in the request: the claim may be omitted and defaults to sha-256 (OID4VP B.3.3.1). + plain := vectorByName(t, "conforming_compact") + require.NoError(t, ts12Case{entries: []string{plain.Raw}, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": []string{plain.Hashes["sha-256"]}, + }}.validate(t)) + requireTxError(t, ts12Case{entries: []string{plain.Raw}, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": []string{plain.Hashes["sha-384"]}, "transaction_data_hashes_alg": "sha-384", + }}.validate(t), "not one the request allowed") +} + +func TestTS12Response_HashesMissingCountAndOrder(t *testing.T) { + a := vectorByName(t, "conforming_compact") + b := vectorByName(t, "conforming_escapes") + entries := []string{a.Raw, b.Raw} + // both reference "pay" + both := func(h ...string) ts12Case { + return ts12Case{entries: entries, queryID: "pay", claims: map[string]any{"transaction_data_hashes": h}} + } + require.NoError(t, both(a.Hashes["sha-256"], b.Hashes["sha-256"]).validate(t)) + requireTxError(t, both(b.Hashes["sha-256"], a.Hashes["sha-256"]).validate(t), "mismatch") + requireTxError(t, both(a.Hashes["sha-256"]).validate(t), "count mismatch") + requireTxError(t, ts12Case{entries: entries, queryID: "pay"}.validate(t), "transaction_data_hashes missing") +} + +// A presentation of a credential no entry references owes no hashes; one an +// entry does reference owes exactly its own, not the other credential's. +func TestTS12Response_ScopedToTheCredentialsEntries(t *testing.T) { + pay := vectorByName(t, "conforming_compact") // credential_ids ["pay"] + other := decodedFor(t, `{"type":"urn:eudi:sca:payment:1","credential_ids":["age"],"payload":{}}`) + entries := []string{other, pay.Raw} + + require.NoError(t, ts12Case{entries: entries, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": []string{pay.Hashes["sha-256"]}, + }}.validate(t)) + // Including the other credential's hash too is a count mismatch. + oh, _ := HashTransactionData([]string{other}, "sha-256") + requireTxError(t, ts12Case{entries: entries, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": []string{oh[0], pay.Hashes["sha-256"]}, + }}.validate(t), "count mismatch") + // Nothing references this query: nothing to check. + require.NoError(t, ts12Case{entries: entries, queryID: "unrelated"}.validate(t)) +} + +func decodedFor(t *testing.T, js string) string { + t.Helper() + return base64.RawURLEncoding.EncodeToString([]byte(js)) +} + +func ts12Entry(t *testing.T) (string, string) { + v := vectorByName(t, "conforming_compact") + return v.Raw, v.Hashes["sha-256"] +} + +func TestTS12Response_AMR(t *testing.T) { + raw, h := ts12Entry(t) + run := func(amr any) error { + return ts12Case{entries: []string{raw}, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": []string{h}, "amr": amr, + }}.validate(t) + } + require.NoError(t, run(goodAMR)) + require.NoError(t, run([]any{ + map[string]any{"possession": "key_in_remote_wscd"}, map[string]any{"inherence": "face_device"}, map[string]any{"possession": "other"}, + })) + + requireTxError(t, run(nil), "amr missing") + requireTxError(t, run([]any{"hwk", "pop"}), "exactly one member") // RFC 8176 strings, what the WSCD manager emitted + requireTxError(t, run([]any{map[string]any{"possession": "key_in_local_external_wscd"}}), "at least 2") + requireTxError(t, run([]any{ + map[string]any{"possession": "key_in_local_external_wscd"}, map[string]any{"possession": "key_in_remote_wscd"}, + }), "at least 2") + requireTxError(t, run([]any{ + map[string]any{"knowledge": "pin_6_or_more_digits", "possession": "other"}, + }), "exactly one member") + requireTxError(t, run([]any{ + map[string]any{"knowledge": "pin_6_or_more_digits"}, map[string]any{"biometric": "other"}, + }), "unknown category") + requireTxError(t, run([]any{ + map[string]any{"knowledge": "pin_6_or_more_digits"}, map[string]any{"possession": "pin_6_or_more_digits"}, + }), "not a TS12 possession") + requireTxError(t, run([]any{ + map[string]any{"knowledge": "pin_6_or_more_digits"}, map[string]any{"possession": 1}, + }), "not a TS12 possession") +} + +func TestTS12Response_JTIAndResponseMode(t *testing.T) { + raw, h := ts12Entry(t) + base := func() ts12Case { + return ts12Case{entries: []string{raw}, queryID: "pay", claims: map[string]any{"transaction_data_hashes": []string{h}}} + } + + c := base() + c.claims["jti"] = nil + requireTxError(t, c.validate(t), "jti missing") + c = base() + c.claims["jti"] = " " + requireTxError(t, c.validate(t), "jti missing") + + // Replay: same jti twice through one store. + store := &memJTIStore{} + c = base() + c.store, c.claims["jti"] = store, "fixed-jti" + require.NoError(t, c.validate(t)) + requireTxError(t, c.validate(t), "used before") + + // Fail closed when freshness cannot be established. + c = base() + c.store = &memJTIStore{err: errors.New("db down")} + requireTxError(t, c.validate(t), "cannot verify jti") + + c = base() + c.claims["response_mode"] = nil + requireTxError(t, c.validate(t), "response_mode missing") + c = base() + c.claims["response_mode"] = "direct_post" + requireTxError(t, c.validate(t), "not one this request was issued with") + c = base() + c.modes = []string{} + requireTxError(t, c.validate(t), "cannot verify response_mode") + c = base() + c.claims["response_mode"] = "dc_api.jwt" + require.NoError(t, c.validate(t), "the DC API variant of the same request is a legitimate channel") +} + +// Without a store the validator cannot honour jti uniqueness, so an SCA +// presentation is refused rather than waved through. +func TestTS12Response_NoStoreRefuses(t *testing.T) { + raw, h := ts12Entry(t) + holder := testHolderKey(t) + tok := testPresentation(t, testCredentialNaming(t, holder, nil), holder, map[string]any{ + "nonce": "n", "aud": "a", "iat": time.Now().Unix(), "jti": "j", "response_mode": "direct_post.jwt", + "amr": goodAMR, "transaction_data_hashes": []string{h}, + }) + err := (&VPTokenValidator{Nonce: "n", ClientID: "a", TransactionData: []string{raw}, CredentialQueryID: "pay", ResponseModes: []string{"direct_post.jwt"}}).Validate(tok) + requireTxError(t, err, "no replay store") +} + +// A non-SCA transaction type keeps the generic OID4VP rules only. +func TestTS12Response_NonSCATypeNeedsNoSCAClaims(t *testing.T) { + raw := decodedFor(t, `{"type":"https://example.com/other","credential_ids":["pay"]}`) + hs, _ := HashTransactionData([]string{raw}, "sha-256") + require.NoError(t, ts12Case{entries: []string{raw}, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": hs, "amr": nil, "jti": nil, "response_mode": nil, + }}.validate(t)) +} + +func TestTransactionDataCoverage(t *testing.T) { + a := decodedFor(t, `{"type":"urn:eudi:sca:payment:1","credential_ids":["pay"]}`) + b := decodedFor(t, `{"type":"urn:eudi:sca:payment:1","credential_ids":["age","other"]}`) + un, err := UncoveredTransactionData([]string{a, b}, []string{"pay"}) + require.NoError(t, err) + assert.Equal(t, []string{b}, un) + un, err = UncoveredTransactionData([]string{a, b}, []string{"pay", "other"}) + require.NoError(t, err) + assert.Empty(t, un) + _, err = UncoveredTransactionData([]string{"!!"}, nil) + assert.Error(t, err, "an entry that cannot be read is an error, not an uncovered-nothing") +} diff --git a/pkg/openid4vp/validator.go b/pkg/openid4vp/validator.go index 0e3d1fa12..dc4298813 100644 --- a/pkg/openid4vp/validator.go +++ b/pkg/openid4vp/validator.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "slices" "strings" "time" @@ -65,6 +66,21 @@ type VPTokenValidator struct { // TransactionData from the Authorization Request (raw base64url strings). When non-empty, the validator checks that the KB-JWT contains matching transaction_data_hashes. TransactionData []string + // CredentialQueryID is the DCQL credential query id this presentation + // answers. When set, only the TransactionData entries whose credential_ids + // reference it are checked against this presentation's KB-JWT (see + // TransactionDataForCredential for why). Empty means every entry. + CredentialQueryID string + + // ResponseModes are the response_mode values the request was issued with. + // An EC TS12 KB-JWT must echo one of them; empty means the claim cannot + // be checked, which refuses an SCA presentation. + ResponseModes []string + + // JTIStore records the KB-JWT jti of an SCA presentation to refuse replay. + // Required for EC TS12 requests; nil refuses them. + JTIStore JTIStore + // MaxKeyBindingAge bounds how old a Key Binding JWT's iat may be. // Zero means defaultMaxKeyBindingAge. MaxKeyBindingAge time.Duration @@ -384,70 +400,86 @@ func (v *VPTokenValidator) validateAudience(parsed *sdjwtvc.ParsedCredential) er } // validateTransactionData verifies that the KB-JWT contains transaction_data_hashes -// that match the TransactionData from the authorization request per OpenID4VP §8.4. +// that match the TransactionData from the authorization request per OpenID4VP +// Appendix B.3.3.1 and, when an entry is an EC TS12 SCA type, the TS12 v1.0.1 +// section 3.6 claims. Every failure is invalid_transaction_data: a response +// this cannot positively match to what was sent is refused. func (v *VPTokenValidator) validateTransactionData(parsed *sdjwtvc.ParsedCredential) error { if len(parsed.KeyBinding) == 0 { return fmt.Errorf("no key binding found for transaction data validation") } + entries, err := decodeTransactionData(v.TransactionData) + if err != nil { + return txError("%v", err) + } + if v.CredentialQueryID != "" { + scoped := entries[:0:0] + for _, e := range entries { + if slices.Contains(e.CredentialIDs, v.CredentialQueryID) { + scoped = append(scoped, e) + } + } + entries = scoped + } + if len(entries) == 0 { + // Nothing in the request is bound to this credential. + return nil + } + kbJWT := strings.Join(parsed.KeyBinding, ".") kbClaims, err := parseKeyBindingJWT(kbJWT) if err != nil { return fmt.Errorf("failed to parse key binding JWT: %w", err) } - // Extract transaction_data_hashes_alg - hashAlg, ok := kbClaims["transaction_data_hashes_alg"].(string) - if !ok || hashAlg == "" { - return &ErrorResponse{ - ErrorCode: ErrorInvalidTransactionData, - ErrorDescription: "transaction_data_hashes_alg missing from KB-JWT", + // transaction_data_hashes_alg: a STRING in the KB-JWT. REQUIRED when the + // request carried a list; the sha-256 default applies when it did not. + allowed := allowedHashAlgs(entries) + hashAlg := TransactionDataHashAlgDefault + if claim, present := kbClaims["transaction_data_hashes_alg"]; present { + s, ok := claim.(string) + if !ok || s == "" { + return txError("transaction_data_hashes_alg in KB-JWT must be a string") } + hashAlg = s + } else if slices.ContainsFunc(entries, func(e decodedTransactionData) bool { return e.HasAlgList }) { + return txError("transaction_data_hashes_alg missing from KB-JWT") + } + if !slices.Contains(allowed, hashAlg) { + return txError("transaction_data_hashes_alg %q is not one the request allowed", hashAlg) } - // Extract transaction_data_hashes array hashesRaw, ok := kbClaims["transaction_data_hashes"].([]any) if !ok || len(hashesRaw) == 0 { - return &ErrorResponse{ - ErrorCode: ErrorInvalidTransactionData, - ErrorDescription: "transaction_data_hashes missing from KB-JWT", - } + return txError("transaction_data_hashes missing from KB-JWT") } - - expectedCount := len(v.TransactionData) - - if len(hashesRaw) != expectedCount { - return &ErrorResponse{ - ErrorCode: ErrorInvalidTransactionData, - ErrorDescription: fmt.Sprintf("transaction_data_hashes count mismatch: expected %d, got %d", expectedCount, len(hashesRaw)), - } + if len(hashesRaw) != len(entries) { + return txError("transaction_data_hashes count mismatch: expected %d, got %d", len(entries), len(hashesRaw)) } - // Re-compute expected hashes from the original transaction data - expectedHashes, err := HashTransactionData(v.TransactionData, hashAlg) + raws := make([]string, len(entries)) + for i, e := range entries { + raws[i] = e.Raw + } + // Over the strings as sent, never a re-encoding of a decoded object. + expectedHashes, err := HashTransactionData(raws, hashAlg) if err != nil { - return &ErrorResponse{ - ErrorCode: ErrorInvalidTransactionData, - ErrorDescription: fmt.Sprintf("unsupported transaction_data_hashes_alg %q: %v", hashAlg, err), - } + return txError("unsupported transaction_data_hashes_alg %q: %v", hashAlg, err) } - for i, raw := range hashesRaw { got, ok := raw.(string) if !ok { - return &ErrorResponse{ - ErrorCode: ErrorInvalidTransactionData, - ErrorDescription: fmt.Sprintf("transaction_data_hashes[%d] is not a string", i), - } + return txError("transaction_data_hashes[%d] is not a string", i) } if got != expectedHashes[i] { - return &ErrorResponse{ - ErrorCode: ErrorInvalidTransactionData, - ErrorDescription: fmt.Sprintf("transaction_data_hashes[%d] mismatch", i), - } + return txError("transaction_data_hashes[%d] mismatch", i) } } + if slices.ContainsFunc(entries, func(e decodedTransactionData) bool { return strings.HasPrefix(e.Type, TS12TypePrefix) }) { + return v.validateTS12Claims(kbClaims) + } return nil } From b89c280a1568644e02b9ecf65e838da6a38228a2 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:20:15 +0200 Subject: [PATCH 03/20] feat(verifier): operator test page for EC TS12 payment requests /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 --- docs/TS12_PAYMENT_SCA.md | 56 +++++++++ .../httpserver/endpoints_payment_test.go | 81 ++++++++++++ internal/verifier/httpserver/endpoints_ui.go | 15 +++ .../verifier/httpserver/endpoints_ui_test.go | 4 + internal/verifier/httpserver/service.go | 2 + internal/verifier/staticembed/payment.html | 118 ++++++++++++++++++ pkg/model/config.go | 2 +- 7 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 docs/TS12_PAYMENT_SCA.md create mode 100644 internal/verifier/httpserver/endpoints_payment_test.go create mode 100644 internal/verifier/staticembed/payment.html diff --git a/docs/TS12_PAYMENT_SCA.md b/docs/TS12_PAYMENT_SCA.md new file mode 100644 index 000000000..9ff7ff75e --- /dev/null +++ b/docs/TS12_PAYMENT_SCA.md @@ -0,0 +1,56 @@ +# EC TS12 payment SCA in the verifier + +The standalone verifier can drive a payment Strong Customer Authentication +test against an EUDI wallet (EC TS12 v1.0.1, `urn:eudi:sca:payment:1`). + +## Enable + +```yaml +verifier: + ts12: + enable: true # default false + sca_vct_values: # optional: pre-fills the test page + - "https://psp.example/sca-attestation" + hash_algs: ["sha-256"] # optional; sent as transaction_data_hashes_alg +``` + +`enable` turns on the `payment` member of `POST /ui/interaction` and the test +page. Anyone who can reach `/ui/interaction` can then start a payment request, +so use it on test and PSP verifiers, not on a general-purpose one. + +## Start a payment request + +Browser: open `https:///payment`, fill in amount, currency, payee +and the SCA attestation's `vct`, and scan the QR code (or follow the link) with +the wallet. The page waits for the wallet and shows whether the response was +accepted. + +Without a browser: + +```sh +curl -s https:///ui/interaction -H 'Content-Type: application/json' -d '{ + "dcql_query": {"credentials": [{"id": "sca", "format": "dc+sd-jwt", + "meta": {"vct_values": ["https://psp.example/sca-attestation"]}}]}, + "payment": {"amount": "49.99", "currency": "EUR", + "payee_name": "Shop AB", "payee_id": "SE1234567890", + "credential_id": "sca"} +}' +# -> {"session_id": ..., "authorization_request": "openid4vp://...", "qr_code": ""} +curl -s "https:///ui/completion?session_id=" # {"status":"pending"} | {"status":"complete","response_code":...} +curl -s "https:///ui/result?response_code=" # the accepted presentation's claims +``` + +`credential_id` may be omitted when the DCQL query has a single credential +query. `transaction_id` defaults to a fresh UUID. The credential query must be +`dc+sd-jwt` with holder binding (TS12 v1.0.1 covers SD-JWT VC only). + +## What is checked on the response + +For each credential query a `transaction_data` entry references: the KB-JWT +`transaction_data_hashes` (over the strings as sent, in request order, with a +`transaction_data_hashes_alg` the request allowed), `nonce`, `aud`, `iat`, +`sd_hash`, and for TS12 types `amr` (>= 2 different categories, TS12 +vocabulary), `response_mode` (one the request was issued with) and `jti` +(present, never seen before). Anything that cannot be checked is refused with +`invalid_transaction_data`; a refused response leaves the page waiting, and the +reason is in the verifier log. diff --git a/internal/verifier/httpserver/endpoints_payment_test.go b/internal/verifier/httpserver/endpoints_payment_test.go new file mode 100644 index 000000000..fcd4182fc --- /dev/null +++ b/internal/verifier/httpserver/endpoints_payment_test.go @@ -0,0 +1,81 @@ +package httpserver + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/SUNET/vc/pkg/logger" + "github.com/SUNET/vc/pkg/model" + "github.com/SUNET/vc/pkg/trace" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func paymentPageService(t *testing.T, enable bool, vcts []string) *Service { + t.Helper() + log, err := logger.New("test", "", false) + require.NoError(t, err) + tracer, err := trace.NewForTesting(context.Background(), "test", log) + require.NoError(t, err) + return &Service{ + cfg: &model.Cfg{ + Common: &model.Common{}, + Verifier: &model.Verifier{TS12: model.TS12Config{Enable: enable, SCAVCTValues: vcts}}, + }, + tracer: tracer, + } +} + +func paymentPageEngine(t *testing.T, enable bool, vcts []string) *gin.Engine { + t.Helper() + engine, _ := setupUIEndpointEngine(t, &uiInteractionApiv1{}) + // setupUIEndpointEngine returns the engine only; build the page route on + // a Service of the same shape. + svc := paymentPageService(t, enable, vcts) + engine.SetHTMLTemplate(htmlTemplates()) + engine.GET("/payment", func(c *gin.Context) { _, _ = svc.endpointPaymentPage(c.Request.Context(), c) }) + return engine +} + +func TestPaymentPage(t *testing.T) { + t.Run("not found unless ts12 is enabled", func(t *testing.T) { + w := httptest.NewRecorder() + paymentPageEngine(t, false, nil).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/payment", nil)) + assert.Equal(t, http.StatusNotFound, w.Code) + }) + t.Run("served when enabled, with the configured vct", func(t *testing.T) { + w := httptest.NewRecorder() + paymentPageEngine(t, true, []string{"https://psp.example/sca"}).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/payment", nil)) + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + assert.Contains(t, body, "urn:eudi:sca:payment:1") + assert.Contains(t, body, "https://psp.example/sca") + assert.NotContains(t, body, "{{", "template actions must all have been expanded") + }) +} + +// The payment object the page posts must survive the HTTP binding into the +// handler: amount as a JSON string and as a JSON number. +func TestUIInteractionBindsPayment(t *testing.T) { + for _, amount := range []string{`"49.99"`, `49.99`} { + mock := &uiInteractionApiv1{replySessionID: "s"} + engine, _ := setupUIEndpointEngine(t, mock) + body := `{"dcql_query":{"credentials":[{"id":"sca","format":"dc+sd-jwt","meta":{"vct_values":["x"]}}]},` + + `"payment":{"amount":` + amount + `,"currency":"EUR","payee_name":"Shop AB","payee_id":"SE1","credential_id":"sca"}}` + req := httptest.NewRequest(http.MethodPost, "/ui/interaction", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NotNil(t, mock.receivedPayment, "amount %s", amount) + assert.Equal(t, "49.99", mock.receivedPayment.Amount.String()) + assert.Equal(t, "sca", mock.receivedPayment.CredentialID) + assert.True(t, strings.HasPrefix(mock.receivedPayment.PayeeName, "Shop")) + } +} diff --git a/internal/verifier/httpserver/endpoints_ui.go b/internal/verifier/httpserver/endpoints_ui.go index 6845d1fe0..d9dbfff46 100644 --- a/internal/verifier/httpserver/endpoints_ui.go +++ b/internal/verifier/httpserver/endpoints_ui.go @@ -24,6 +24,21 @@ func (s *Service) endpointIndex(ctx context.Context, c *gin.Context) (any, error return nil, nil } +// endpointPaymentPage serves the operator test page for EC TS12 payment +// requests. Not found unless verifier.ts12.enable is set: the page and the +// "payment" member of /ui/interaction it drives are one feature. +func (s *Service) endpointPaymentPage(ctx context.Context, c *gin.Context) (any, error) { + _, span := s.tracer.Start(ctx, "httpserver:endpointPaymentPage") + defer span.End() + + if !s.cfg.Verifier.TS12.Enable { + c.AbortWithStatus(http.StatusNotFound) + return nil, nil + } + c.HTML(http.StatusOK, "payment.html", s.cfg.Verifier.TS12) + return nil, nil +} + func (s *Service) endpointUIMetadata(ctx context.Context, c *gin.Context) (any, error) { ctx, span := s.tracer.Start(ctx, "httpserver:endpointUIMetadata") defer span.End() diff --git a/internal/verifier/httpserver/endpoints_ui_test.go b/internal/verifier/httpserver/endpoints_ui_test.go index c8e79849b..e30a7005e 100644 --- a/internal/verifier/httpserver/endpoints_ui_test.go +++ b/internal/verifier/httpserver/endpoints_ui_test.go @@ -32,6 +32,9 @@ type uiInteractionApiv1 struct { // receivedSessionID is set by UIInteraction; nil = not called yet. receivedSessionID *string + // receivedPayment is the "payment" member the endpoint forwarded. + receivedPayment *apiv1.UIPaymentRequest + // replySessionID is returned as UIInteractionReply.SessionID. Set per // test to simulate either reuse (same as the hint) or a fresh mint. replySessionID string @@ -56,6 +59,7 @@ type uiInteractionApiv1 struct { func (u *uiInteractionApiv1) UIInteraction(ctx context.Context, req *apiv1.UIInteractionRequest) (*apiv1.UIInteractionReply, error) { got := req.SessionID u.receivedSessionID = &got + u.receivedPayment = req.Payment return &apiv1.UIInteractionReply{SessionID: u.replySessionID}, nil } diff --git a/internal/verifier/httpserver/service.go b/internal/verifier/httpserver/service.go index c809ea786..6c038ce0d 100644 --- a/internal/verifier/httpserver/service.go +++ b/internal/verifier/httpserver/service.go @@ -198,6 +198,8 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, notify *notif // TODO(masv): no polling, use WebSocket or Server-Sent Events instead s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, "poll/:session_id", http.StatusOK, s.endpointPollSession) + s.httpHelpers.Server.RegEndpoint(ctx, rgRoot, http.MethodGet, "payment", http.StatusOK, s.endpointPaymentPage) + rgUI := rgOAuthSession.Group("/ui") s.httpHelpers.Server.RegEndpoint(ctx, rgUI, http.MethodPost, "/interaction", http.StatusOK, s.endpointUIInteraction) s.httpHelpers.Server.RegEndpoint(ctx, rgUI, http.MethodGet, "/notify", http.StatusOK, s.endpointUINotify) diff --git a/internal/verifier/staticembed/payment.html b/internal/verifier/staticembed/payment.html new file mode 100644 index 000000000..72b494462 --- /dev/null +++ b/internal/verifier/staticembed/payment.html @@ -0,0 +1,118 @@ + + + + + + Verifier - test payment (EC TS12) + + + + + +
+
+

Test payment request (EC TS12)

+

Sends a urn:eudi:sca:payment:1 transaction_data request for an SCA attestation and shows whether the wallet's response is accepted. Test tool: enabled by verifier.ts12.enable.

+ +
+
+
+
+
+
+
+
+
+
+
+

Exact vct of the attestation the wallet must present (comma separated for several).

+ +
+ + + + + +
+
+ + + diff --git a/pkg/model/config.go b/pkg/model/config.go index 0207b3712..4f49e4f59 100644 --- a/pkg/model/config.go +++ b/pkg/model/config.go @@ -1440,7 +1440,7 @@ type TS12Config struct { // SCAVCTValues are the vct values of the SCA attestation the operator test // page requests by default. Optional; the page lets the operator type one. - SCAVCTValues []string `yaml:"sca_vct_values,omitempty"` + SCAVCTValues []string `yaml:"sca_vct_values,omitempty" json:"sca_vct_values,omitempty"` // HashAlgs is the transaction_data_hashes_alg list sent with each payment // request, in order of preference. Default ["sha-256"] (OpenID4VP 1.0 From eef938a4922bfaea42053bc4b1ad62d170ac8f76 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:28:52 +0200 Subject: [PATCH 04/20] fix(verifier): tighten TS12 payment input validation (review) - 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 --- internal/verifier/apiv1/handler_ts12.go | 4 +++ internal/verifier/apiv1/handler_ts12_test.go | 3 +++ pkg/openid4vp/ts12.go | 28 +++++++++++++++++--- pkg/openid4vp/ts12_test.go | 17 ++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go index 3e7d1159d..206070ad3 100644 --- a/internal/verifier/apiv1/handler_ts12.go +++ b/internal/verifier/apiv1/handler_ts12.go @@ -58,6 +58,10 @@ func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4v } else { for i := range dcql.Credentials { if dcql.Credentials[i].ID == p.CredentialID { + if target != nil { + // DCQL requires unique ids; two matches leave it unclear which query's format and holder binding apply. + return nil, invalidPayment("credential_id %q names more than one credential query", p.CredentialID) + } target = &dcql.Credentials[i] } } diff --git a/internal/verifier/apiv1/handler_ts12_test.go b/internal/verifier/apiv1/handler_ts12_test.go index 5705669f3..cee83748a 100644 --- a/internal/verifier/apiv1/handler_ts12_test.go +++ b/internal/verifier/apiv1/handler_ts12_test.go @@ -59,6 +59,8 @@ func TestUIInteraction_PaymentRefusals(t *testing.T) { multi := scaDCQL("sca") multi.Credentials = append(multi.Credentials, openid4vp.CredentialQuery{ID: "age", Format: openid4vp.FormatSDJWTVC, Meta: openid4vp.MetaQuery{VCTValues: []string{"x"}}}) mdoc := &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ID: "m", Format: "mso_mdoc", Meta: openid4vp.MetaQuery{DoctypeValue: "org.iso.18013.5.1.mDL"}}}} + dup := scaDCQL("sca") + dup.Credentials = append(dup.Credentials, openid4vp.CredentialQuery{ID: "sca", Format: openid4vp.FormatSDJWTVC, Meta: openid4vp.MetaQuery{VCTValues: []string{"other"}}}) noBinding := scaDCQL("sca") f := false noBinding.Credentials[0].RequireCryptographicHolderBinding = &f @@ -71,6 +73,7 @@ func TestUIInteraction_PaymentRefusals(t *testing.T) { "feature disabled": {false, scaDCQL("sca"), nil}, "two queries without credential_id": {true, multi, nil}, "unknown credential_id": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.CredentialID = "nope" }}, + "duplicate credential query id": {true, dup, func(p *UIPaymentRequest) { p.CredentialID = "sca" }}, "mdoc is out of TS12 scope": {true, mdoc, nil}, "holder binding disabled": {true, noBinding, nil}, "bad currency": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Currency = "euro" }}, diff --git a/pkg/openid4vp/ts12.go b/pkg/openid4vp/ts12.go index 7f3a4424d..1975ba8e8 100644 --- a/pkg/openid4vp/ts12.go +++ b/pkg/openid4vp/ts12.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strings" + "unicode/utf8" ) // EC TS12 v1.0.1 ("Specification of Strong Customer Authentication (SCA) @@ -21,7 +22,6 @@ const ( ) var ( - ts12CurrencyRE = regexp.MustCompile(`^[A-Z]{3}$`) // ts12AmountRE: TS12 4.3.1 says amount is a JSON number "consisting of major currency units as the integer component and an optional fraction part". A plain non-negative decimal, no exponent, no sign. ts12AmountRE = regexp.MustCompile(`^(0|[1-9][0-9]*)(\.[0-9]+)?$`) ts12ZeroRE = regexp.MustCompile(`^0+(\.0+)?$`) @@ -69,7 +69,8 @@ func (n *RawNumber) UnmarshalJSON(b []byte) error { // Validate applies the TS12 payment schema constraints this verifier can check, failing closed. func (p *TS12PaymentPayload) Validate() error { - if l := len(p.TransactionID); l < 1 || l > MaxTS12TransactionIDLength { + // JSON Schema maxLength counts Unicode code points, not UTF-8 bytes. + if l := utf8.RuneCountInString(p.TransactionID); l < 1 || l > MaxTS12TransactionIDLength { return fmt.Errorf("transaction_id must be 1..%d characters", MaxTS12TransactionIDLength) } if strings.TrimSpace(p.Payee.Name) == "" { @@ -78,8 +79,8 @@ func (p *TS12PaymentPayload) Validate() error { if strings.TrimSpace(p.Payee.ID) == "" { return fmt.Errorf("payee.id is required") } - if !ts12CurrencyRE.MatchString(p.Currency) { - return fmt.Errorf("currency must be an ISO 4217 alpha-3 code") + if !iso4217Currencies[p.Currency] { + return fmt.Errorf("currency %q is not an ISO 4217 alpha-3 currency code", p.Currency) } if !ts12AmountRE.MatchString(string(p.Amount)) { return fmt.Errorf("amount must be a plain decimal number") @@ -121,3 +122,22 @@ func NewTS12PaymentTransactionData(payload TS12PaymentPayload, credentialIDs []s Payload: payload, }, nil } + +// iso4217Currencies is a snapshot of the ISO 4217 alpha-3 codes of currencies +// in circulation (no precious metals, test or "no currency" codes, which are +// not payment currencies). A code missing here is refused: fail closed beats +// sending a code no wallet or payment system can interpret. BGN and HRK are +// not listed (both withdrawn for the euro). +var iso4217Currencies = func() map[string]bool { + codes := strings.Fields(`AED AFN ALL AMD AOA ARS AUD AWG AZN BAM BBD BDT BHD BIF BMD BND BOB BRL BSD BTN BWP BYN BZD + CAD CDF CHF CLP CNY COP CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD + HKD HNL HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD + MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG + QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD + TZS UAH UGX USD UYU UZS VES VND VUV WST XAF XCD XCG XOF XPF YER ZAR ZMW ZWG`) + m := make(map[string]bool, len(codes)) + for _, c := range codes { + m[c] = true + } + return m +}() diff --git a/pkg/openid4vp/ts12_test.go b/pkg/openid4vp/ts12_test.go index e80268493..7067687a5 100644 --- a/pkg/openid4vp/ts12_test.go +++ b/pkg/openid4vp/ts12_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -130,6 +131,22 @@ func TestNewTS12PaymentTransactionData(t *testing.T) { }) } + t.Run("transaction_id length counts characters, not bytes", func(t *testing.T) { + p := good + p.TransactionID = strings.Repeat("é", 36) // 72 bytes + _, err := NewTS12PaymentTransactionData(p, []string{"sca"}, nil) + assert.NoError(t, err) + p.TransactionID = strings.Repeat("é", 37) + _, err = NewTS12PaymentTransactionData(p, []string{"sca"}, nil) + assert.Error(t, err) + }) + for _, c := range []string{"EUR", "SEK", "USD", "JPY", "XOF"} { + p := good + p.Currency = c + _, err := NewTS12PaymentTransactionData(p, []string{"sca"}, nil) + assert.NoError(t, err, c) + } + _, err = NewTS12PaymentTransactionData(good, nil, nil) assert.Error(t, err, "credential_ids required") _, err = NewTS12PaymentTransactionData(good, []string{"sca"}, []string{"md5"}) From 4fdd0695e9784107c36584aef7eafd1276eef5f8 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:29:27 +0200 Subject: [PATCH 05/20] fix(verifier): payment page hides panels, resets state, retries polling (review) Co-Authored-By: Claude Sonnet 5.5 --- .../httpserver/endpoints_payment_test.go | 1 + internal/verifier/staticembed/payment.html | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/internal/verifier/httpserver/endpoints_payment_test.go b/internal/verifier/httpserver/endpoints_payment_test.go index fcd4182fc..d5f9db156 100644 --- a/internal/verifier/httpserver/endpoints_payment_test.go +++ b/internal/verifier/httpserver/endpoints_payment_test.go @@ -56,6 +56,7 @@ func TestPaymentPage(t *testing.T) { body := w.Body.String() assert.Contains(t, body, "urn:eudi:sca:payment:1") assert.Contains(t, body, "https://psp.example/sca") + assert.Contains(t, body, "[hidden] { display: none !important; }", "Bulma's .box would otherwise keep hidden panels visible") assert.NotContains(t, body, "{{", "template actions must all have been expanded") }) } diff --git a/internal/verifier/staticembed/payment.html b/internal/verifier/staticembed/payment.html index 72b494462..b8db789db 100644 --- a/internal/verifier/staticembed/payment.html +++ b/internal/verifier/staticembed/payment.html @@ -7,6 +7,7 @@ +
@@ -60,6 +61,10 @@

Test payment request (EC TS12)

ev.preventDefault(); $("error").hidden = true; $("done").hidden = true; + $("pending").hidden = true; + $("qr").removeAttribute("src"); + $("link").href = "#"; + $("status").textContent = "Waiting for the wallet..."; $("submit").disabled = true; var vcts = $("vct").value.split(",").map(function (s) { return s.trim(); }).filter(Boolean); var body = { @@ -94,11 +99,15 @@

Test payment request (EC TS12)

// /ui/completion answers {"status":"pending"} until the wallet's response has been accepted. // A refused response leaves it pending: the reason is in the verifier log (invalid_transaction_data). for (var i = 0; i < 150; i++) { - var res = await fetch("/ui/completion?session_id=" + encodeURIComponent(sessionID), { cache: "no-store" }); - if (res.ok) { - var c = await res.json(); - if (c.status === "complete") { return show(c.response_code); } - } + // The session exists whatever happens to one poll: a network or JSON + // failure is retried within the same limit, not reported as a failed start. + try { + var res = await fetch("/ui/completion?session_id=" + encodeURIComponent(sessionID), { cache: "no-store" }); + if (res.ok) { + var c = await res.json(); + if (c.status === "complete") { return show(c.response_code); } + } + } catch (e) { /* retry */ } await sleep(2000); } $("status").textContent = "Timed out waiting for the wallet. If the wallet answered, the verifier refused the response: see its log."; From 07af6eb1a66dd2f8af9b7a4a1ea63efe3ec7adeb Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:35:54 +0200 Subject: [PATCH 06/20] feat(verifier): record issued response modes on the TS12 session 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 --- internal/verifier/apiv1/handler_ts12.go | 19 ++++++++++ internal/verifier/apiv1/handler_ts12_test.go | 37 ++++++++++++++++++++ internal/verifier/apiv1/handlers_ui.go | 18 ++++++---- pkg/cache/authcontext_types.go | 6 ++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go index 206070ad3..5cf564350 100644 --- a/internal/verifier/apiv1/handler_ts12.go +++ b/internal/verifier/apiv1/handler_ts12.go @@ -72,6 +72,12 @@ func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4v if target.Format != openid4vp.FormatSDJWTVC { return nil, invalidPayment("credential query %q has format %q; TS12 payment SCA requires %q", target.ID, target.Format, openid4vp.FormatSDJWTVC) } + if len(target.Meta.VCTValues) == 0 { + // Without a vct constraint any credential the trust policy accepts + // could authorize the payment: the response check compares the + // presented credential's vct against these values. + return nil, invalidPayment("credential query %q must name the SCA attestation with meta.vct_values", target.ID) + } if !target.RequiresCryptographicHolderBinding() { return nil, invalidPayment("credential query %q disables cryptographic holder binding, which transaction_data requires", target.ID) } @@ -95,3 +101,16 @@ func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4v } return []string{encoded}, nil } + +// issuedResponseModes are the response_mode values the verifier puts in the +// requests it issues for /ui/interaction: direct_post.jwt behind the QR code +// and link, and dc_api.jwt for the Digital Credentials API variant when that +// is enabled. The response arrives at one endpoint whichever was used, so a +// TS12 KB-JWT may echo either. Recorded on the session at issuance. +func (c *Client) issuedResponseModes() []string { + modes := []string{openid4vp.ResponseModeDirectPostJWT} + if c.cfg.Verifier.DigitalCredentials.Enable { + modes = append(modes, openid4vp.ResponseModeDCAPIJWT) + } + return modes +} diff --git a/internal/verifier/apiv1/handler_ts12_test.go b/internal/verifier/apiv1/handler_ts12_test.go index cee83748a..4c4850749 100644 --- a/internal/verifier/apiv1/handler_ts12_test.go +++ b/internal/verifier/apiv1/handler_ts12_test.go @@ -126,3 +126,40 @@ func TestUIInteraction_PaymentSessionsAreNeverReused(t *testing.T) { require.NoError(t, err) assert.Empty(t, authCtx.TransactionData) } + +// The response is checked against the modes the session was issued with, so +// they are recorded when the request is made, per the config at that time. +func TestUIInteraction_PaymentRecordsIssuedResponseModes(t *testing.T) { + ctx := t.Context() + for _, dcAPI := range []bool{false, true} { + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + client.cfg.Verifier.DigitalCredentials.Enable = dcAPI + reply, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), Payment: testPayment()}) + if dcAPI && err != nil { + t.Skipf("DC API origin not configurable in this test client: %v", err) + } + require.NoError(t, err) + authCtx, err := client.cacheService.AuthContext.GetByID(ctx, reply.SessionID) + require.NoError(t, err) + want := []string{"direct_post.jwt"} + if dcAPI { + want = append(want, "dc_api.jwt") + } + assert.Equal(t, want, authCtx.TransactionDataResponseModes, "dcAPI=%v", dcAPI) + } +} + +// UIInteraction's own DCQL validation already demands vct_values for SD-JWT +// VC; the payment builder checks it again because the response check compares +// the presented vct against exactly these values. +func TestBuildPaymentTransactionData_RequiresVCTValues(t *testing.T) { + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + dcql := scaDCQL("sca") + _, err := client.buildPaymentTransactionData(testPayment(), dcql) + require.NoError(t, err) + dcql.Credentials[0].Meta.VCTValues = nil + _, err = client.buildPaymentTransactionData(testPayment(), dcql) + require.Error(t, err) +} diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go index fe3307ccb..3d5bfe724 100644 --- a/internal/verifier/apiv1/handlers_ui.go +++ b/internal/verifier/apiv1/handlers_ui.go @@ -527,8 +527,9 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( // request object AND the session: OpenID4VP hashes the string as // received, so what we compare the wallet's hashes against must be the // bytes we sent, not a later re-encoding. - var transactionData []string + var transactionData, transactionDataModes []string if req.Payment != nil { + transactionDataModes = c.issuedResponseModes() var err error transactionData, err = c.buildPaymentTransactionData(req.Payment, req.DCQLQuery) if err != nil { @@ -559,12 +560,15 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( // verification path cannot see what it is checking against. DCQLQuery: req.DCQLQuery, TransactionData: transactionData, - Code: "", - RequestURI: "", - WalletURI: "", - Forfeited: false, - State: state, - ClientID: uiClientID, + // What the request objects below are issued with, kept so the + // response is checked against it and not against a later config. + TransactionDataResponseModes: transactionDataModes, + Code: "", + RequestURI: "", + WalletURI: "", + Forfeited: false, + State: state, + ClientID: uiClientID, // How long the user has to complete the presentation. // // This was 0, and isSessionActive reads 0 as "never expires" - so diff --git a/pkg/cache/authcontext_types.go b/pkg/cache/authcontext_types.go index bd9b85828..fee2c876f 100644 --- a/pkg/cache/authcontext_types.go +++ b/pkg/cache/authcontext_types.go @@ -160,6 +160,12 @@ type AuthorizationContext struct { // Appendix B hashes the string as received, so validation must use these // bytes and never a re-encoding of a decoded object. TransactionData []string `json:"transaction_data,omitempty" bson:"transaction_data,omitempty"` + // TransactionDataResponseModes are the response_mode values the request + // objects for this session were issued with. An EC TS12 KB-JWT echoes the + // response_mode of the request, so it is checked against what was issued + // at the time, not against whatever the receiving node is configured with + // now. + TransactionDataResponseModes []string `json:"transaction_data_response_modes,omitempty" bson:"transaction_data_response_modes,omitempty"` // ScopeQueryIDs maps a requested scope to the id of the DCQL credential // query that stands for it, for the scopes where the two differ. // From aed5ba6fc3ceb72978e27fe673e8dcd918bd40c4 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:38:13 +0200 Subject: [PATCH 07/20] fix(verifier): payment page clears the previous result and retries result fetch (review) Co-Authored-By: Claude Sonnet 5.5 --- internal/verifier/staticembed/payment.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/verifier/staticembed/payment.html b/internal/verifier/staticembed/payment.html index b8db789db..d573f38f1 100644 --- a/internal/verifier/staticembed/payment.html +++ b/internal/verifier/staticembed/payment.html @@ -61,6 +61,8 @@

Test payment request (EC TS12)

ev.preventDefault(); $("error").hidden = true; $("done").hidden = true; + $("result").textContent = ""; + $("verdict").textContent = ""; $("pending").hidden = true; $("qr").removeAttribute("src"); $("link").href = "#"; @@ -105,7 +107,7 @@

Test payment request (EC TS12)

var res = await fetch("/ui/completion?session_id=" + encodeURIComponent(sessionID), { cache: "no-store" }); if (res.ok) { var c = await res.json(); - if (c.status === "complete") { return show(c.response_code); } + if (c.status === "complete") { return await show(c.response_code); } } } catch (e) { /* retry */ } await sleep(2000); From 8bbbbe45b00688b10d16e5885b117c5216ecf096 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:46:16 +0200 Subject: [PATCH 08/20] fix(verifier): payment refusals render as 400, resume stays within issued 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 --- internal/verifier/apiv1/handler_ts12.go | 6 +++- internal/verifier/apiv1/handler_ts12_test.go | 30 ++++++++++++++++- internal/verifier/apiv1/handlers_ui.go | 7 +++- internal/verifier/httpserver/endpoints_ui.go | 10 ++++++ .../httpserver/endpoints_ui_binding_test.go | 33 +++++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 internal/verifier/httpserver/endpoints_ui_binding_test.go diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go index 5cf564350..b008cffff 100644 --- a/internal/verifier/apiv1/handler_ts12.go +++ b/internal/verifier/apiv1/handler_ts12.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/SUNET/vc/pkg/oauth2" "github.com/SUNET/vc/pkg/openid4vp" "github.com/google/uuid" @@ -30,7 +31,10 @@ type UIPaymentRequest struct { } func invalidPayment(format string, args ...any) error { - return &OAuthError{ + // The shared type: RegEndpoint renders pkg/oauth2.OAuthError (status and + // description); the verifier-local OAuthError would fall through to the + // generic handler and lose both. + return &oauth2.OAuthError{ ErrorCode: ErrCodeInvalidRequest, ErrorDescription: "payment: " + fmt.Sprintf(format, args...), HTTPStatus: http.StatusBadRequest, diff --git a/internal/verifier/apiv1/handler_ts12_test.go b/internal/verifier/apiv1/handler_ts12_test.go index 4c4850749..2cec27a4b 100644 --- a/internal/verifier/apiv1/handler_ts12_test.go +++ b/internal/verifier/apiv1/handler_ts12_test.go @@ -6,6 +6,7 @@ import ( "errors" "testing" + "github.com/SUNET/vc/pkg/oauth2" "github.com/SUNET/vc/pkg/openid4vp" "github.com/stretchr/testify/assert" @@ -89,7 +90,7 @@ func TestUIInteraction_PaymentRefusals(t *testing.T) { } _, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: tc.dcql, Payment: p}) require.Error(t, err) - oe, ok := errors.AsType[*OAuthError](err) + oe, ok := errors.AsType[*oauth2.OAuthError](err) require.True(t, ok, "a client mistake must be a 400, got %v", err) assert.Equal(t, 400, oe.HTTPStatus) }) @@ -163,3 +164,30 @@ func TestBuildPaymentTransactionData_RequiresVCTValues(t *testing.T) { _, err = client.buildPaymentTransactionData(testPayment(), dcql) require.Error(t, err) } + +// A payment session is checked against the response modes it was issued with, +// so resuming it must not mint a request variant outside them. +func TestUIResume_PaymentDoesNotIssueAnUnrecordedDCAPIVariant(t *testing.T) { + ctx := t.Context() + + client := newSigningTestClient(t) + client.cfg.Verifier.TS12.Enable = true + first, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), Payment: testPayment()}) + require.NoError(t, err) + assert.Empty(t, first.DCAPIAuthorizationRequest) + + client.cfg.Verifier.DigitalCredentials.Enable = true // switched on after issuance + resumed, err := client.UIResume(ctx, first.SessionID) + require.NoError(t, err) + require.Equal(t, UIResumePending, resumed.Status) + assert.NotEmpty(t, resumed.AuthorizationRequest) + assert.Empty(t, resumed.DCAPIAuthorizationRequest, "the session was not issued dc_api.jwt") + + // And a session that WAS issued with it still resumes with it. + again, err := client.UIInteraction(ctx, &UIInteractionRequest{DCQLQuery: scaDCQL("sca"), Payment: testPayment()}) + require.NoError(t, err) + assert.NotEmpty(t, again.DCAPIAuthorizationRequest) + resumed, err = client.UIResume(ctx, again.SessionID) + require.NoError(t, err) + assert.NotEmpty(t, resumed.DCAPIAuthorizationRequest) +} diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go index 3d5bfe724..41c9b884d 100644 --- a/internal/verifier/apiv1/handlers_ui.go +++ b/internal/verifier/apiv1/handlers_ui.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/url" + "slices" "sort" "strings" "time" @@ -1032,7 +1033,11 @@ func (c *Client) UIResume(ctx context.Context, sessionID string) (*UIResumeReply if err != nil { return nil, err } - if c.cfg.Verifier.DigitalCredentials.Enable { + // A payment session records the response modes it was issued with and the + // response is checked against them, so a resume must not issue a variant + // that was not among them (the DC API switched on since). + dcAPIAllowed := len(authCtx.TransactionData) == 0 || slices.Contains(authCtx.TransactionDataResponseModes, openid4vp.ResponseModeDCAPIJWT) + if c.cfg.Verifier.DigitalCredentials.Enable && dcAPIAllowed { reply.DCAPIAuthorizationRequest, err = c.dcAPIAuthorizationRequest(ctx, requestObject) if err != nil { return nil, err diff --git a/internal/verifier/httpserver/endpoints_ui.go b/internal/verifier/httpserver/endpoints_ui.go index 6845d1fe0..ca6ccc008 100644 --- a/internal/verifier/httpserver/endpoints_ui.go +++ b/internal/verifier/httpserver/endpoints_ui.go @@ -2,6 +2,7 @@ package httpserver import ( "context" + "encoding/json" "errors" "io" "net/http" @@ -9,6 +10,7 @@ import ( "github.com/SUNET/vc/internal/verifier/apiv1" "github.com/SUNET/vc/pkg/cache" + "github.com/SUNET/vc/pkg/oauth2" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" @@ -156,6 +158,14 @@ func (s *Service) endpointUIInteraction(ctx context.Context, c *gin.Context) (an request := &apiv1.UIInteractionRequest{} if err := s.httpHelpers.Binding.Request(ctx, c, request); err != nil { + // A body of the wrong shape (a payment amount that is a boolean, say) + // is the caller's mistake, not a server fault; unwrapped it reaches + // the status mapper as an unrecognised error and becomes a 500. + var typeErr *json.UnmarshalTypeError + var syntaxErr *json.SyntaxError + if errors.As(err, &typeErr) || errors.As(err, &syntaxErr) || errors.Is(err, io.ErrUnexpectedEOF) { + return nil, &oauth2.OAuthError{ErrorCode: apiv1.ErrCodeInvalidRequest, ErrorDescription: "request body is not valid for this endpoint", HTTPStatus: http.StatusBadRequest, Cause: err} + } return nil, err } diff --git a/internal/verifier/httpserver/endpoints_ui_binding_test.go b/internal/verifier/httpserver/endpoints_ui_binding_test.go new file mode 100644 index 000000000..5fb6183fc --- /dev/null +++ b/internal/verifier/httpserver/endpoints_ui_binding_test.go @@ -0,0 +1,33 @@ +package httpserver + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A body of the wrong shape is the caller's mistake: 400 with a description, +// not the 500 an unrecognised error becomes in the status mapper. +func TestUIInteraction_MalformedBodyIs400(t *testing.T) { + cases := map[string]string{ + "amount is a boolean": `{"dcql_query":{"credentials":[]},"payment":{"amount":true,"currency":"EUR","payee_name":"n","payee_id":"i"}}`, + "amount is an object": `{"dcql_query":{"credentials":[]},"payment":{"amount":{"x":1},"currency":"EUR","payee_name":"n","payee_id":"i"}}`, + "payment is a string": `{"dcql_query":{"credentials":[]},"payment":"49.99"}`, + "not json": `{"dcql_query":`, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + engine, _ := setupUIEndpointEngine(t, &uiInteractionApiv1{replySessionID: "s"}) + req := httptest.NewRequest(http.MethodPost, "/ui/interaction", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "invalid_request") + }) + } +} From 465380e2a393e79d453134b43b79ec06c6403152 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:47:57 +0200 Subject: [PATCH 09/20] fix(verifier): payment page retries a transient /ui/result error (review) Co-Authored-By: Claude Sonnet 5.5 --- internal/verifier/staticembed/payment.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/verifier/staticembed/payment.html b/internal/verifier/staticembed/payment.html index d573f38f1..4fb522085 100644 --- a/internal/verifier/staticembed/payment.html +++ b/internal/verifier/staticembed/payment.html @@ -117,11 +117,14 @@

Test payment request (EC TS12)

async function show(code) { var res = await fetch("/ui/result?response_code=" + encodeURIComponent(code), { cache: "no-store" }); + // A server error is transient: throw so the polling loop retries the same session. + if (res.status >= 500) { throw new Error("HTTP " + res.status); } + var text = res.ok ? JSON.stringify(await res.json(), null, 2) : ""; $("pending").hidden = true; $("done").hidden = false; if (!res.ok) { $("verdict").textContent = "Result not available (HTTP " + res.status + ")"; return; } $("verdict").textContent = "Payment authorised: the wallet's response was accepted"; - $("result").textContent = JSON.stringify(await res.json(), null, 2); + $("result").textContent = text; } })(); From 481b04e8a5aa5100e19a02e9062d7e844f3fc868 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:53:08 +0200 Subject: [PATCH 10/20] fix(verifier): a non-numeric payment amount is a 400, not a status-mapper 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 --- internal/verifier/apiv1/handler_ts12.go | 29 +++++++++++++++++-- internal/verifier/apiv1/handler_ts12_test.go | 27 ++++++++++++----- .../httpserver/endpoints_ui_binding_test.go | 2 -- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go index b008cffff..f4c52e851 100644 --- a/internal/verifier/apiv1/handler_ts12.go +++ b/internal/verifier/apiv1/handler_ts12.go @@ -17,7 +17,7 @@ import ( // buildPaymentTransactionData, never the other way round. type UIPaymentRequest struct { // Amount is the payment amount in major currency units, as a JSON number or a numeric string ("49.99"). Must be greater than zero. - Amount json.Number `json:"amount" validate:"required"` + Amount PaymentAmount `json:"amount" validate:"required"` // Currency is an ISO 4217 alpha-3 code. Currency string `json:"currency" validate:"required"` // PayeeName and PayeeID are REQUIRED by the TS12 payment schema. @@ -30,6 +30,31 @@ type UIPaymentRequest struct { CredentialID string `json:"credential_id,omitempty" validate:"omitempty,max=128"` } +// PaymentAmount is the amount as the caller wrote it: a JSON number or a +// string. Decoding keeps the literal text and judges nothing, so that a bad +// amount ("token", true, {}) is refused by buildPaymentTransactionData as a +// 400 invalid_request with a description, instead of failing inside the JSON +// decoder as an error the status mapper cannot classify. +type PaymentAmount string + +// UnmarshalJSON implements json.Unmarshaler. +func (a *PaymentAmount) UnmarshalJSON(b []byte) error { + if len(b) > 0 && b[0] == '"' { + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *a = PaymentAmount(s) + return nil + } + if string(b) == "null" { + *a = "" + return nil + } + *a = PaymentAmount(b) + return nil +} + func invalidPayment(format string, args ...any) error { // The shared type: RegEndpoint renders pkg/oauth2.OAuthError (status and // description); the verifier-local OAuthError would fall through to the @@ -94,7 +119,7 @@ func (c *Client) buildPaymentTransactionData(p *UIPaymentRequest, dcql *openid4v TransactionID: txID, Payee: openid4vp.TS12Payee{Name: p.PayeeName, ID: p.PayeeID, Website: p.PayeeWebsite}, Currency: p.Currency, - Amount: openid4vp.RawNumber(p.Amount.String()), + Amount: openid4vp.RawNumber(p.Amount), }, []string{target.ID}, c.cfg.Verifier.TS12.HashAlgs) if err != nil { return nil, invalidPayment("%v", err) diff --git a/internal/verifier/apiv1/handler_ts12_test.go b/internal/verifier/apiv1/handler_ts12_test.go index 2cec27a4b..00d41777e 100644 --- a/internal/verifier/apiv1/handler_ts12_test.go +++ b/internal/verifier/apiv1/handler_ts12_test.go @@ -71,14 +71,17 @@ func TestUIInteraction_PaymentRefusals(t *testing.T) { dcql *openid4vp.DCQL mut func(p *UIPaymentRequest) }{ - "feature disabled": {false, scaDCQL("sca"), nil}, - "two queries without credential_id": {true, multi, nil}, - "unknown credential_id": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.CredentialID = "nope" }}, - "duplicate credential query id": {true, dup, func(p *UIPaymentRequest) { p.CredentialID = "sca" }}, - "mdoc is out of TS12 scope": {true, mdoc, nil}, - "holder binding disabled": {true, noBinding, nil}, - "bad currency": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Currency = "euro" }}, - "zero amount": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Amount = "0" }}, + "feature disabled": {false, scaDCQL("sca"), nil}, + "two queries without credential_id": {true, multi, nil}, + "unknown credential_id": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.CredentialID = "nope" }}, + "duplicate credential query id": {true, dup, func(p *UIPaymentRequest) { p.CredentialID = "sca" }}, + "mdoc is out of TS12 scope": {true, mdoc, nil}, + "holder binding disabled": {true, noBinding, nil}, + "bad currency": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Currency = "euro" }}, + "amount is a word that trips the status mapper": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Amount = "token" }}, + "amount is a boolean literal": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Amount = "true" }}, + "amount is an object literal": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Amount = `{"x":1}` }}, + "zero amount": {true, scaDCQL("sca"), func(p *UIPaymentRequest) { p.Amount = "0" }}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -191,3 +194,11 @@ func TestUIResume_PaymentDoesNotIssueAnUnrecordedDCAPIVariant(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, resumed.DCAPIAuthorizationRequest) } + +func TestPaymentAmount_DecodesNumbersAndStringsWithoutJudging(t *testing.T) { + for in, want := range map[string]string{`49.99`: "49.99", `"49.99"`: "49.99", `49.990`: "49.990", `"token"`: "token", `true`: "true", `null`: ""} { + var p UIPaymentRequest + require.NoError(t, json.Unmarshal([]byte(`{"amount":`+in+`}`), &p), in) + assert.Equal(t, PaymentAmount(want), p.Amount, in) + } +} diff --git a/internal/verifier/httpserver/endpoints_ui_binding_test.go b/internal/verifier/httpserver/endpoints_ui_binding_test.go index 5fb6183fc..f55936cc9 100644 --- a/internal/verifier/httpserver/endpoints_ui_binding_test.go +++ b/internal/verifier/httpserver/endpoints_ui_binding_test.go @@ -14,8 +14,6 @@ import ( // not the 500 an unrecognised error becomes in the status mapper. func TestUIInteraction_MalformedBodyIs400(t *testing.T) { cases := map[string]string{ - "amount is a boolean": `{"dcql_query":{"credentials":[]},"payment":{"amount":true,"currency":"EUR","payee_name":"n","payee_id":"i"}}`, - "amount is an object": `{"dcql_query":{"credentials":[]},"payment":{"amount":{"x":1},"currency":"EUR","payee_name":"n","payee_id":"i"}}`, "payment is a string": `{"dcql_query":{"credentials":[]},"payment":"49.99"}`, "not json": `{"dcql_query":`, } From 5ff4aca7a21f70d2b7143d28cd078ef13a7e07c3 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:53:32 +0200 Subject: [PATCH 11/20] test(verifier): adapt payment binding test to PaymentAmount Co-Authored-By: Claude Sonnet 5.5 --- internal/verifier/httpserver/endpoints_payment_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/verifier/httpserver/endpoints_payment_test.go b/internal/verifier/httpserver/endpoints_payment_test.go index d5f9db156..b5c4f0436 100644 --- a/internal/verifier/httpserver/endpoints_payment_test.go +++ b/internal/verifier/httpserver/endpoints_payment_test.go @@ -75,7 +75,7 @@ func TestUIInteractionBindsPayment(t *testing.T) { engine.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code, w.Body.String()) require.NotNil(t, mock.receivedPayment, "amount %s", amount) - assert.Equal(t, "49.99", mock.receivedPayment.Amount.String()) + assert.Equal(t, "49.99", string(mock.receivedPayment.Amount)) assert.Equal(t, "sca", mock.receivedPayment.CredentialID) assert.True(t, strings.HasPrefix(mock.receivedPayment.PayeeName, "Shop")) } From b8bccf5e4677c025b73a2644d82efdb311bf1152 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Thu, 15 Oct 2026 22:58:34 +0200 Subject: [PATCH 12/20] fix(verifier): payment page does not report an accepted response as refused (review) Co-Authored-By: Claude Sonnet 5.5 --- internal/verifier/staticembed/payment.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/verifier/staticembed/payment.html b/internal/verifier/staticembed/payment.html index 4fb522085..36f724a9d 100644 --- a/internal/verifier/staticembed/payment.html +++ b/internal/verifier/staticembed/payment.html @@ -100,6 +100,7 @@

Test payment request (EC TS12)

async function poll(sessionID) { // /ui/completion answers {"status":"pending"} until the wallet's response has been accepted. // A refused response leaves it pending: the reason is in the verifier log (invalid_transaction_data). + var completed = false; for (var i = 0; i < 150; i++) { // The session exists whatever happens to one poll: a network or JSON // failure is retried within the same limit, not reported as a failed start. @@ -107,12 +108,14 @@

Test payment request (EC TS12)

var res = await fetch("/ui/completion?session_id=" + encodeURIComponent(sessionID), { cache: "no-store" }); if (res.ok) { var c = await res.json(); - if (c.status === "complete") { return await show(c.response_code); } + if (c.status === "complete") { completed = true; return await show(c.response_code); } } } catch (e) { /* retry */ } await sleep(2000); } - $("status").textContent = "Timed out waiting for the wallet. If the wallet answered, the verifier refused the response: see its log."; + $("status").textContent = completed + ? "The verifier accepted the response, but its result could not be fetched. Check the verifier log before starting another request." + : "Timed out waiting for the wallet. If the wallet answered, the verifier may have refused the response: see its log."; } async function show(code) { From b86f4e7abd19ce73fdb3dea0158758b3d95c7d5e Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Mon, 19 Oct 2026 17:05:09 +0200 Subject: [PATCH 13/20] fix(openid4vp): clear PresentedJTI at the start of each Validate 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) Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum --- pkg/openid4vp/ts12_response_test.go | 44 +++++++++++++++++++++++++++++ pkg/openid4vp/validator.go | 11 ++++++++ 2 files changed, 55 insertions(+) diff --git a/pkg/openid4vp/ts12_response_test.go b/pkg/openid4vp/ts12_response_test.go index 5b04e9d44..c457f5bae 100644 --- a/pkg/openid4vp/ts12_response_test.go +++ b/pkg/openid4vp/ts12_response_test.go @@ -251,6 +251,50 @@ func TestTS12Response_JTIAndResponseMode(t *testing.T) { require.NoError(t, c.validate(t), "the DC API variant of the same request is a legitimate channel") } +// A reused validator must not carry the previous presentation's jti. +// +// 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 reusing that jti. So a stale value is not a cosmetic +// leak: validate an SCA presentation, then reuse the validator for an +// unrelated one that sets no jti, and the caller claims the first jti a +// second time and rejects a valid presentation as a replay. +func TestTS12Response_ReusedValidatorDoesNotCarryTheJTI(t *testing.T) { + holder := testHolderKey(t) + cred := testCredentialNaming(t, holder, nil) + + scaEntry, hash := ts12Entry(t) + v := &VPTokenValidator{ + Nonce: "n-1", ClientID: "x509_hash:verifier", + TransactionData: []string{scaEntry}, CredentialQueryID: "pay", + ResponseModes: []string{"direct_post.jwt"}, + } + + sca := testPresentation(t, cred, holder, map[string]any{ + "nonce": "n-1", "aud": "x509_hash:verifier", "iat": time.Now().Unix(), + "response_mode": "direct_post.jwt", "amr": goodAMR, + "jti": "first-jti", "transaction_data_hashes": []string{hash}, + }) + require.NoError(t, v.Validate(sca)) + require.Equal(t, "first-jti", v.PresentedJTI, "the premise: the first presentation reports a jti to claim") + + // The same validator, now pointed at an unrelated query with no SCA + // entry. Nothing writes PresentedJTI on this path. + other := decodedFor(t, `{"type":"https://example.com/other","credential_ids":["age"]}`) + hashes, err := HashTransactionData([]string{other}, "sha-256") + require.NoError(t, err) + v.TransactionData = []string{other} + v.CredentialQueryID = "age" + + second := testPresentation(t, cred, holder, map[string]any{ + "nonce": "n-1", "aud": "x509_hash:verifier", "iat": time.Now().Unix(), + "transaction_data_hashes": hashes, + }) + require.NoError(t, v.Validate(second)) + assert.Empty(t, v.PresentedJTI, + "a presentation that reports no jti must leave nothing for the caller to claim") +} + // A non-SCA transaction type keeps the generic OID4VP rules only. func TestTS12Response_NonSCATypeNeedsNoSCAClaims(t *testing.T) { raw := decodedFor(t, `{"type":"https://example.com/other","credential_ids":["pay"]}`) diff --git a/pkg/openid4vp/validator.go b/pkg/openid4vp/validator.go index d1bb51e17..f9ca22a47 100644 --- a/pkg/openid4vp/validator.go +++ b/pkg/openid4vp/validator.go @@ -90,6 +90,17 @@ type VPTokenValidator struct { // Validate validates the VP Token according to OpenID4VP spec Section 8.6 func (v *VPTokenValidator) Validate(vpToken string) error { + // The jti output belongs to THIS presentation, so it is cleared + // before anything can read it. + // + // It is written only when an SCA entry applies, and the caller claims + // a non-empty value in a JTIStore - a state change that refuses the + // presentation if the value was already claimed. A validator reused + // for a second presentation that is not an SCA one would otherwise + // still carry the first jti, and the caller would claim it a second + // time and reject an unrelated, valid presentation as a replay. + v.PresentedJTI = "" + // 1. Validate VP Token format (Section 8.1) token := sdjwtvc.Token(vpToken) parsed, err := token.Parse() From 9641323f1537d376034a026b77a6289529abb6cb Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Mon, 19 Oct 2026 17:08:40 +0200 Subject: [PATCH 14/20] fix(verifier): a malformed /ui/interaction request is a structured 400 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) Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum --- internal/verifier/apiv1/handler_ts12.go | 29 ++++++- .../apiv1/handler_ui_request_status_test.go | 80 +++++++++++++++++++ internal/verifier/apiv1/handlers_ui.go | 9 ++- 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 internal/verifier/apiv1/handler_ui_request_status_test.go diff --git a/internal/verifier/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go index f4c52e851..73e1737d9 100644 --- a/internal/verifier/apiv1/handler_ts12.go +++ b/internal/verifier/apiv1/handler_ts12.go @@ -55,13 +55,40 @@ func (a *PaymentAmount) UnmarshalJSON(b []byte) error { return nil } +// invalidInteraction refuses an interaction request with a structured +// 400, carrying the payment prefix when the request WAS a payment one so +// that a payment refused before buildPaymentTransactionData runs reads +// like every other payment refusal rather than like a different class of +// failure. +func invalidInteraction(payment bool, format string, args ...any) error { + if payment { + return invalidPayment(format, args...) + } + return invalidInteractionRequest(format, args...) +} + func invalidPayment(format string, args ...any) error { + return invalidInteractionRequest("%s", "payment: "+fmt.Sprintf(format, args...)) +} + +// invalidInteractionRequest returns a structured 400 for an interaction +// request the caller got wrong. +// +// Structured because an unstructured error carries no status: it reaches +// httphelpers.StatusCode, which INFERS one from the message text. A +// credential query the caller named "token" produces "credential query +// \"token\" is invalid: ...", which matches that inference's +// "unauthorized, authentication, token" case - so a malformed request is +// answered 401, before the "invalid" case is ever reached, and the +// description is dropped on the way out. A caller-chosen identifier must +// not be able to choose the status code. +func invalidInteractionRequest(format string, args ...any) error { // The shared type: RegEndpoint renders pkg/oauth2.OAuthError (status and // description); the verifier-local OAuthError would fall through to the // generic handler and lose both. return &oauth2.OAuthError{ ErrorCode: ErrCodeInvalidRequest, - ErrorDescription: "payment: " + fmt.Sprintf(format, args...), + ErrorDescription: fmt.Sprintf(format, args...), HTTPStatus: http.StatusBadRequest, } } diff --git a/internal/verifier/apiv1/handler_ui_request_status_test.go b/internal/verifier/apiv1/handler_ui_request_status_test.go new file mode 100644 index 000000000..6b44b5ae0 --- /dev/null +++ b/internal/verifier/apiv1/handler_ui_request_status_test.go @@ -0,0 +1,80 @@ +package apiv1 + +import ( + "net/http" + "testing" + + "github.com/SUNET/vc/pkg/httphelpers" + "github.com/SUNET/vc/pkg/openid4vp" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A caller must not be able to choose the status code of its own refusal. +// +// An unstructured error carries no status, so httphelpers.StatusCode +// INFERS one from the message text. The message names the credential +// query id, which the caller chose: "credential query \"token\" is +// invalid: ..." matches the "unauthorized, authentication, token" case +// and is answered 401 - before the "invalid" case is reached - so a +// malformed request reads as an authentication failure and the +// description is dropped on the way out. +// +// The payment path makes it worse, because the operator page handles a +// structured payment error and nothing else: a payment refused by this +// loop, before buildPaymentTransactionData ever runs, looked like a +// different class of failure than the same request refused one step +// later. +func TestUIInteraction_AnInvalidCredentialQueryIsA400(t *testing.T) { + client := newSigningTestClient(t) + + // Named to hit the inference: "token" is one of the substrings it + // reads as an authentication failure. + bad := func(id string) *openid4vp.DCQL { + return &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{{ + ID: id, + Format: openid4vp.FormatSDJWTVC, + // No meta: an SD-JWT VC query with no vct_values matches + // every credential of the format, which ValidateCredentialQuery + // refuses. + }}} + } + + for _, tc := range []struct { + name string + req *UIInteractionRequest + wantMsg string + }{ + { + name: "an ordinary interaction request", + req: &UIInteractionRequest{DCQLQuery: bad("token")}, + wantMsg: "credential query \"token\" is invalid", + }, + { + name: "a payment request refused before the payment is built", + req: &UIInteractionRequest{ + DCQLQuery: bad("token"), + Payment: &UIPaymentRequest{ + Amount: PaymentAmount("49.99"), Currency: "EUR", + PayeeName: "Shop", PayeeID: "shop-1", + }, + }, + wantMsg: "payment: credential query \"token\" is invalid", + }, + { + name: "a DCQL asking for nothing at all", + req: &UIInteractionRequest{DCQLQuery: &openid4vp.DCQL{Credentials: []openid4vp.CredentialQuery{}}}, + wantMsg: "at least one credential", + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := client.UIInteraction(t.Context(), tc.req) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantMsg, + "the refusal must say what was wrong with the request") + assert.Equal(t, http.StatusBadRequest, httphelpers.StatusCode(t.Context(), err), + "a malformed request is a 400; a caller-chosen identifier must not change that") + }) + } +} diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go index 41c9b884d..eb72119e6 100644 --- a/internal/verifier/apiv1/handlers_ui.go +++ b/internal/verifier/apiv1/handlers_ui.go @@ -512,15 +512,20 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( // scopes, finds none, and reports success for a response that answered // a request asking for nothing. Refused here, where the caller can still // be told what was wrong with its request. + // + // Both refusals here are STRUCTURED 400s. An unstructured error carries + // no status, so the status is inferred from its text - and the text + // contains a credential query id the caller chose. One named "token" + // answers 401 instead of 400 and loses the description with it. if len(req.DCQLQuery.Credentials) == 0 { c.log.Error(nil, "rejected a UI interaction request whose DCQL asks for no credential at all") - return nil, errors.New("dcql_query must request at least one credential") + return nil, invalidInteraction(req.Payment != nil, "dcql_query must request at least one credential") } for _, credential := range req.DCQLQuery.Credentials { if err := openid4vp.ValidateCredentialQuery(credential); err != nil { c.log.Error(err, "rejected an invalid credential query from the UI interaction request", "credential_id", credential.ID, "format", credential.Format) - return nil, fmt.Errorf("credential query %q is invalid: %w", credential.ID, err) + return nil, invalidInteraction(req.Payment != nil, "credential query %q is invalid: %v", credential.ID, err) } } From 45bb219811ca347d99e8b43ba6f3837672be7fd3 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Mon, 19 Oct 2026 17:10:08 +0200 Subject: [PATCH 15/20] fix(verifier): the payment page must not swallow a decode failure 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) Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum --- .../httpserver/endpoints_payment_test.go | 34 +++++++++++++++++++ internal/verifier/staticembed/payment.html | 16 +++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/internal/verifier/httpserver/endpoints_payment_test.go b/internal/verifier/httpserver/endpoints_payment_test.go index b5c4f0436..a5cd649e5 100644 --- a/internal/verifier/httpserver/endpoints_payment_test.go +++ b/internal/verifier/httpserver/endpoints_payment_test.go @@ -59,6 +59,40 @@ func TestPaymentPage(t *testing.T) { assert.Contains(t, body, "[hidden] { display: none !important; }", "Bulma's .box would otherwise keep hidden panels visible") assert.NotContains(t, body, "{{", "template actions must all have been expanded") }) + + // A successful /ui/interaction response that cannot be read must fail + // loudly, not quietly become {}. + // + // The page used one decode with an empty fallback for BOTH outcomes. + // On a truncated or unreadable success body that left reply = {}, so + // the page 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. There is no JS test runner here, so this pins + // the structure: the empty fallback lives on the error branch only, + // and the success branch checks that the reply carries what the page + // goes on to use. + t.Run("a successful reply is decoded without a silent fallback", func(t *testing.T) { + w := httptest.NewRecorder() + paymentPageEngine(t, true, []string{"https://psp.example/sca"}).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/payment", nil)) + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + + start := strings.Index(body, "await fetch(\"/ui/interaction\"") + require.Positive(t, start, "the submit handler must still post to /ui/interaction") + end := strings.Index(body[start:], "await poll(") + require.Positive(t, end, "the submit handler must still start polling") + handler := body[start : start+end] + + ok := strings.Index(handler, "if (!res.ok)") + require.Positive(t, ok, "the handler must branch on res.ok") + success := handler[strings.Index(handler, "var reply = await res.json()"):] + require.NotEmpty(t, success, "the success path must decode the reply") + assert.NotContains(t, success, "catch(", + "a decode failure on a SUCCESSFUL response must propagate, not become an empty object") + assert.Contains(t, success, "is missing", + "and a reply without session_id, qr_code or authorization_request must be refused too") + }) } // The payment object the page posts must survive the HTTP binding into the diff --git a/internal/verifier/staticembed/payment.html b/internal/verifier/staticembed/payment.html index 36f724a9d..5d999b484 100644 --- a/internal/verifier/staticembed/payment.html +++ b/internal/verifier/staticembed/payment.html @@ -83,8 +83,20 @@

Test payment request (EC TS12)

var res = await fetch("/ui/interaction", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); - var reply = await res.json().catch(function () { return {}; }); - if (!res.ok) { throw new Error(reply.error_description || reply.error || ("HTTP " + res.status)); } + if (!res.ok) { + // An ERROR response may legitimately not be JSON, so the + // empty fallback belongs here and only here. + var problem = await res.json().catch(function () { return {}; }); + throw new Error(problem.error_description || problem.error || ("HTTP " + res.status)); + } + // A SUCCESSFUL response has to be readable and complete. Falling + // back to {} here - or accepting a reply missing a member - would + // render a QR code from "undefined", link to "undefined" and then + // poll session_id=undefined 150 times, so a truncated body would + // look like a payment waiting for a wallet rather than a failure. + var reply = await res.json(); + var missing = ["session_id", "qr_code", "authorization_request"].filter(function (k) { return !reply[k]; }); + if (missing.length) { throw new Error("the verifier's reply is missing " + missing.join(", ")); } $("summary").textContent = "Pay " + body.payment.amount + " " + body.payment.currency + " to " + body.payment.payee_name; $("qr").src = "data:image/png;base64," + reply.qr_code; $("link").href = reply.authorization_request; From 647bafb9fd9569d79efd727785adbe9f4517e604 Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Mon, 19 Oct 2026 19:29:23 +0200 Subject: [PATCH 16/20] fix(verifier): the payment page's wallet link marks the session same-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) --- .../httpserver/endpoints_payment_test.go | 38 ++++++ .../verifier/staticembed/payment-handoff.js | 48 +++++++ internal/verifier/staticembed/payment.html | 7 +- .../staticembed/tests/payment-handoff.test.js | 118 ++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 internal/verifier/staticembed/payment-handoff.js create mode 100644 internal/verifier/staticembed/tests/payment-handoff.test.js diff --git a/internal/verifier/httpserver/endpoints_payment_test.go b/internal/verifier/httpserver/endpoints_payment_test.go index a5cd649e5..167d1a2b2 100644 --- a/internal/verifier/httpserver/endpoints_payment_test.go +++ b/internal/verifier/httpserver/endpoints_payment_test.go @@ -60,6 +60,44 @@ func TestPaymentPage(t *testing.T) { assert.NotContains(t, body, "{{", "template actions must all have been expanded") }) + // The wallet link must hand off through the shared helper, so the + // session is marked same-device. + // + // /verification/direct_post only returns redirect_uri for a session + // marked same-device, so without the preference POST the wallet has + // nowhere to send the user back to and the operator has to find this + // page again by hand. It cannot be an awaited fetch before the + // navigation either: Chrome on Android only lets a page launch a + // custom scheme while it still holds the click's transient + // activation, and one await loses it silently. wallet-handoff.js is + // where that ordering is worked out, so the page imports it rather + // than growing a second copy. + // + // Only the LINK is armed: scanning the QR code is a cross-device + // flow and must not be marked same-device. + t.Run("the wallet link hands off through the shared helper", func(t *testing.T) { + w := httptest.NewRecorder() + paymentPageEngine(t, true, []string{"https://psp.example/sca"}).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/payment", nil)) + require.Equal(t, http.StatusOK, w.Code) + body := w.Body.String() + + assert.Contains(t, body, `import { armWalletLink } from "/static/payment-handoff.js"`, + "the page must use the shared handoff, not a second implementation of it") + assert.Contains(t, body, `