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/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/apiv1/handler_ts12.go b/internal/verifier/apiv1/handler_ts12.go new file mode 100644 index 000000000..b17a15401 --- /dev/null +++ b/internal/verifier/apiv1/handler_ts12.go @@ -0,0 +1,225 @@ +package apiv1 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/SUNET/vc/pkg/cache" + "github.com/SUNET/vc/pkg/oauth2" + "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 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. + 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"` +} + +// 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 +} + +// 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: 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 { + 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] + } + } + 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 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) + } + + 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{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 +} + +// 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. +// 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 +} + +// invalidTransactionData refuses a wallet response as a structured 400; an +// unstructured error would have its status guessed from its text (401/404). +func invalidTransactionData(format string, args ...any) *oauth2.OAuthError { + return &oauth2.OAuthError{ + ErrorCode: openid4vp.ErrorInvalidTransactionData, + ErrorDescription: fmt.Sprintf(format, args...), + HTTPStatus: http.StatusBadRequest, + } +} + +// claimJTI records a presented jti, refusing a repeat. With no replay cache it +// refuses: jti uniqueness cannot be honoured, so the presentation cannot be. +func (c *Client) claimJTI(ctx context.Context, jti string) error { + if c.cacheService == nil || c.cacheService.UsedJTI == nil { + return invalidTransactionData("cannot verify jti freshness: no replay store configured") + } + // Atomic: a concurrent second submission of the same KB-JWT loses SetNX. + fresh, err := c.cacheService.UsedJTI.SetNX(ctx, jti, true) + if err != nil { + return invalidTransactionData("cannot verify jti freshness: %v", err) + } + if !fresh { + return invalidTransactionData("jti has been used before") + } + return nil +} + +// checkBoundCredentialType refuses a presented credential whose vct is not one +// the bound credential query asked for. Fails closed: when the query cannot be +// found for this session (for example the DCQL is not available on this +// replica) the type cannot be compared and the presentation is refused. +func (c *Client) checkBoundCredentialType(authCtx *cache.AuthorizationContext, scopeQueryIDs map[string]string, scope string, claims map[string]any) error { + query, ok := c.requestedQueryExact(authCtx, scopeQueryIDs, scope) + if !ok { + return invalidTransactionData("cannot compare the credential type: the request's credential query is not available") + } + if len(query.Meta.VCTValues) == 0 { + return invalidTransactionData("cannot compare the credential type: the request named no vct_values") + } + vct, _ := claims["vct"].(string) + if vct == "" { + return invalidTransactionData("credential has no vct") + } + if !slices.Contains(query.Meta.VCTValues, vct) { + return invalidTransactionData("credential vct %q is not one the request asked for", vct) + } + return nil +} 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..cbff9214b --- /dev/null +++ b/internal/verifier/apiv1/handler_ts12_response_test.go @@ -0,0 +1,300 @@ +package apiv1 + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "net/http" + "testing" + + "github.com/SUNET/vc/internal/verifier/notify" + "github.com/SUNET/vc/pkg/httphelpers" + "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) + if kb != nil { + if _, set := kb["_vct"]; !set { + kb["_vct"] = "https://psp.example/sca" + } + } + 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) + assert.Equal(t, http.StatusBadRequest, httphelpers.StatusCode(t.Context(), err)) + }) + } + + t.Run("refuses a credential of another type", func(t *testing.T) { + r := newTS12Rig(t) + kb := r.kbClaims("jti-othertype") + kb["_vct"] = "urn:eudi:pid:1" + err := r.post(kb) + require.Error(t, err) + assert.Contains(t, err.Error(), "not one the request asked for") + }) + t.Run("refuses a credential with no vct", func(t *testing.T) { + r := newTS12Rig(t) + kb := r.kbClaims("jti-novct") + kb["_vct"] = "" + err := r.post(kb) + require.Error(t, err) + assert.Contains(t, err.Error(), "no vct") + }) + + // The modes come from what the session was issued with, not from the + // configuration of whichever node receives the response. + t.Run("response_mode is checked against the issued modes, not the current config", func(t *testing.T) { + r := newTS12Rig(t) + r.client.cfg.Verifier.DigitalCredentials.Enable = true // enabled after issuance + kb := r.kbClaims("jti-mode-1") + kb["response_mode"] = "dc_api.jwt" + err := r.post(kb) + require.Error(t, err) + assert.Contains(t, err.Error(), "not one this request was issued with") + + r = newTS12Rig(t) + authCtx, err := r.client.cacheService.AuthContext.GetByID(t.Context(), r.sessID) + require.NoError(t, err) + authCtx.TransactionDataResponseModes = []string{"direct_post.jwt", "dc_api.jwt"} // issued with the DC API on + require.NoError(t, r.client.cacheService.AuthContext.Update(t.Context(), authCtx)) + r.client.cfg.Verifier.DigitalCredentials.Enable = false // disabled since + kb = r.kbClaims("jti-mode-2") + kb["response_mode"] = "dc_api.jwt" + require.NoError(t, r.post(kb)) + }) + + 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") + }) + + // The jti is recorded only once the issuer is authenticated: a credential + // the trust policy rejects must not reserve its jti. + t.Run("an untrusted issuer does not burn the jti", func(t *testing.T) { + r := newTS12Rig(t) + r.client.jwtTrustVerifier = trust.NewJWTTrustVerifier(trust.JWTTrustVerifierConfig{ + TrustEvaluator: denyAllEvaluator{}, + JWKSResolver: trust.NewJWKSKeyResolver(trust.JWKSResolverConfig{}), + ParseX5C: func(x5cRaw any) ([]*x509.Certificate, error) { return jose.ParseX5CHeader(x5cRaw) }, + ParseJWK: jose.ParseJWKToPublicKey, + Log: logger.NewSimple("test"), + }) + err := r.post(r.kbClaims("untrusted-jti")) + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer trust evaluation failed") + fresh, err := r.client.cacheService.UsedJTI.SetNX(t.Context(), "untrusted-jti", true) + require.NoError(t, err) + assert.True(t, fresh, "the jti of a rejected presentation must still be unclaimed") + }) + + 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) + } + sca := map[string]any{"_vct": "https://psp.example/sca"} + for k, v := range scaKB { + sca[k] = v + } + body, err := json.Marshal(openid4vp.VPResponse{VPToken: map[string][]string{"sca": {mk(sca)}, "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) +} + +// A response refused after the SCA credential passed must not use up its jti. +func TestVerificationDirectPost_TS12LaterRefusalDoesNotBurnTheJTI(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" + r := newTS12RigFor(t, dcql, p) + + ctx := t.Context() + authCtx, err := r.client.cacheService.AuthContext.GetByID(ctx, r.sessID) + require.NoError(t, err) + sca := r.kbClaims("later-refusal-jti") + sca["_vct"] = "https://psp.example/sca" + scaTok := createTestSDJWTWithKBClaims(t, r.key, map[string]any{"given_name": "Ada"}, nil, authCtx.Nonce, authCtx.ClientID, sca) + // The second credential fails its own nonce check, after the SCA one passed. + ageTok := createTestSDJWTWithKBClaims(t, r.key, map[string]any{"given_name": "Ada"}, nil, "wrong-nonce", authCtx.ClientID, nil) + body, err := json.Marshal(openid4vp.VPResponse{VPToken: map[string][]string{"sca": {scaTok}, "age": {ageTok}}, State: authCtx.State}) + require.NoError(t, err) + _, pub, err := r.client.ephemeralEncryptionKey(ctx, authCtx.EphemeralEncryptionKeyID) + require.NoError(t, err) + enc, err := jwe.Encrypt(body, jwe.WithKey(jwa.ECDH_ES(), pub), jwe.WithContentEncryption(jwa.A256GCM())) + require.NoError(t, err) + _, err = r.client.VerificationDirectPost(ctx, &VerificationDirectPostRequest{Response: string(enc)}) + require.Error(t, err) + + fresh, err := r.client.cacheService.UsedJTI.SetNX(ctx, "later-refusal-jti", true) + require.NoError(t, err) + assert.True(t, fresh, "the jti of a refused response must still be unclaimed") +} + +type denyAllEvaluator struct{} + +func (denyAllEvaluator) Evaluate(_ context.Context, _ *trust.EvaluationRequest) (*trust.TrustDecision, error) { + return &trust.TrustDecision{Trusted: false, Reason: "test: deny all"}, nil +} +func (denyAllEvaluator) SupportsKeyType(trust.KeyType) bool { return true } diff --git a/internal/verifier/apiv1/handler_ts12_test.go b/internal/verifier/apiv1/handler_ts12_test.go new file mode 100644 index 000000000..00d41777e --- /dev/null +++ b/internal/verifier/apiv1/handler_ts12_test.go @@ -0,0 +1,204 @@ +package apiv1 + +import ( + "encoding/base64" + "encoding/json" + "errors" + "testing" + + "github.com/SUNET/vc/pkg/oauth2" + "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"}}}} + 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 + + 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" }}, + "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) { + 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[*oauth2.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) +} + +// 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) +} + +// 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) +} + +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/apiv1/handler_ui_request_status_test.go b/internal/verifier/apiv1/handler_ui_request_status_test.go new file mode 100644 index 000000000..ba41ac6b0 --- /dev/null +++ b/internal/verifier/apiv1/handler_ui_request_status_test.go @@ -0,0 +1,110 @@ +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") + }) + } +} + +// A request with no query at all must refuse, not panic. +// +// The HTTP binding skips decoding a body of zero length entirely - no +// decode, no validation, no error - so a caller that POSTs an empty +// body arrives here with a zero-valued request. Everything in +// UIInteraction dereferences the query, so this used to be a nil +// pointer dereference: a stack trace and a 500 where a structured 400 +// was owed, and on a server without recovery middleware, worse. +func TestUIInteraction_ARequestWithNoQueryRefusesInsteadOfPanicking(t *testing.T) { + client := newSigningTestClient(t) + + for _, tc := range []struct { + name string + req *UIInteractionRequest + }{ + {"a zero-valued request, which is what an empty body produces", &UIInteractionRequest{}}, + {"no request at all", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + require.NotPanics(t, func() { + _, err := client.UIInteraction(t.Context(), tc.req) + require.Error(t, err) + assert.Contains(t, err.Error(), "dcql_query is required") + assert.Equal(t, http.StatusBadRequest, httphelpers.StatusCode(t.Context(), err), + "a request that carries nothing is the caller's mistake") + }) + }) + } +} diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go index 47794a0d8..83d01b613 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" @@ -427,6 +428,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 { @@ -460,6 +468,16 @@ type UIInteractionReply struct { // UIInteraction handles front-end interactions, replying with an Authorization Request that contains a Request URI and DCQL query, the latter for UI to show. func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) (*UIInteractionReply, error) { + // A request carrying no query at all, which is not merely invalid: + // everything below dereferences it, so this PANICS without the + // guard. The HTTP binding skips decoding a body of zero length + // entirely - no decode, no validation, no error - so a caller that + // sends an empty body arrives here with a zero-valued request and + // gets a stack trace and a 500 where a 400 was owed. + if req == nil || req.DCQLQuery == nil { + return nil, invalidInteractionRequest("dcql_query is required") + } + c.log.Debug("uiInteraction", "dcql_query", req.DCQLQuery) // Augment BEFORE the reuse check: the stored request object was @@ -467,6 +485,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 { @@ -498,15 +522,35 @@ 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) + } + } + + // 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, transactionDataModes []string + if req.Payment != nil { + transactionDataModes = c.issuedResponseModes() + 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 } } @@ -530,13 +574,17 @@ 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, + // 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 @@ -624,6 +672,7 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) ( RedirectURI: "", Scope: "", DCQLQuery: req.DCQLQuery, + TransactionData: transactionData, RequestURIMethod: "", VerifierInfo: c.registrationCertificate.VerifierInfo(), } @@ -711,6 +760,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 } @@ -994,7 +1048,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/apiv1/handlers_verification.go b/internal/verifier/apiv1/handlers_verification.go index d68035b9b..8f63b43e2 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, presentedJTIs []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, invalidTransactionData("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, invalidTransactionData("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,9 +324,19 @@ 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 = authCtx.TransactionDataResponseModes + } if err := validator.Validate(responseParams.VPToken); err != nil { c.log.Error(err, "VP Token validation failed", "scope", scope) + if txErr, ok := errors.AsType[*openid4vp.ErrorResponse](err); ok && txErr.ErrorCode == openid4vp.ErrorInvalidTransactionData { + refusal := invalidTransactionData("%s", txErr.ErrorDescription) + refusal.Cause = err + return nil, refusal + } return nil, fmt.Errorf("VP Token validation failed for scope %s: %w", scope, err) } @@ -324,6 +356,22 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi return nil, err } + // A credential that answers a transaction_data-bound query must be + // of the type that query names: the amr and other TS12 claims are + // the wallet's own words, and nothing else ties them to an SCA + // attestation. Trust evaluation does not enforce the requested vct. + if slices.Contains(answeredTransactionQueries, scopeQueryID) { + if err := c.checkBoundCredentialType(authCtx, scopeQueryIDs, scope, parsed.Claims); err != nil { + c.log.Error(err, "credential does not match the transaction_data-bound query", "scope", scope) + return nil, err + } + } + + // Claimed only once the whole response has passed, below. + if validator.PresentedJTI != "" { + presentedJTIs = append(presentedJTIs, validator.PresentedJTI) + } + c.log.Debug("Parsed SD-JWT credential", "scope", scope, "disclosures_count", len(parsed.Disclosures), @@ -791,6 +839,26 @@ 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, invalidTransactionData("transaction_data not authorized by any presented credential") + } + } + + // Record the jti values last, after every check has passed: claiming one + // earlier would let a fabricated or otherwise refused response use it up. + for _, jti := range presentedJTIs { + if err := c.claimJTI(ctx, jti); err != nil { + c.log.Error(err, "jti not accepted") + return nil, err + } + } + // 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..284e9d1ba 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 @@ -616,10 +623,15 @@ func createTestSDJWTWithDecoys(t *testing.T, key *ecdsa.PrivateKey, claims map[s "y": base64.RawURLEncoding.EncodeToString(keyY), } + // "_vct" is a test-only override of the credential type, not a KB claim. + vct := "urn:test:credential" + if v, ok := extraKB["_vct"].(string); ok { + vct = v + } issuerClaims := jwt.MapClaims{ "iss": "https://issuer.example.com", "iat": time.Now().Unix(), - "vct": "urn:test:credential", + "vct": vct, "_sd": sdHashes, // The holder this credential was issued to. These fixtures use // one key for issuer and holder, so naming it here is what makes @@ -651,6 +663,11 @@ 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 { + if k != "_vct" { + 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..b76dcd04e 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 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/internal/verifier/httpserver/endpoints_payment_test.go b/internal/verifier/httpserver/endpoints_payment_test.go new file mode 100644 index 000000000..167d1a2b2 --- /dev/null +++ b/internal/verifier/httpserver/endpoints_payment_test.go @@ -0,0 +1,154 @@ +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.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") + }) + + // 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, ` + + diff --git a/internal/verifier/staticembed/tests/payment-handoff.test.js b/internal/verifier/staticembed/tests/payment-handoff.test.js new file mode 100644 index 000000000..7f429f0b3 --- /dev/null +++ b/internal/verifier/staticembed/tests/payment-handoff.test.js @@ -0,0 +1,118 @@ +// Unit tests for the payment page's wallet link. +// +// The defect these exist for: 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. +// +// Like wallet-handoff.test.js these are ORDER tests as much as result +// tests: the preference must not be awaited before a custom-scheme +// launch, or Android Chrome drops the click's transient activation and +// the launch becomes a silent no-op. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { armWalletLink } from "../payment-handoff.js"; + +const ORIGIN = "https://verifier.example.com"; +const ENDPOINT = `${ORIGIN}/verification/session-preference`; + +function env() { + const calls = []; + return { + calls, + sendBeacon: (url, data) => { calls.push(["sendBeacon", url, data]); return true; }, + // Never settles, so anything after an await provably has not run. + fetch: (url, init) => { calls.push(["fetch", url, init]); return new Promise(() => {}); }, + navigate: (url) => { calls.push(["navigate", url]); }, + openPlaceholder: () => { calls.push(["openPlaceholder"]); return { closed: false, close() { this.closed = true; }, location: {} }; }, + openExternal: (url) => { calls.push(["openExternal", url]); }, + }; +} + +function click(overrides = {}) { + let defaultPrevented = false; + return { + button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, + preventDefault() { defaultPrevented = true; }, + get defaultPrevented() { return defaultPrevented; }, + ...overrides, + }; +} + +describe("armWalletLink", () => { + it("records the session as same-device and launches the wallet", async () => { + const link = { href: "openid4vp://?request_uri=x", onclick: null }; + const e = env(); + armWalletLink(link, "s-1", ORIGIN, e); + + const ev = click(); + link.onclick(ev); + + assert.equal(ev.defaultPrevented, true, "the anchor must not navigate on its own"); + const beacon = e.calls.find((c) => c[0] === "sendBeacon"); + assert.ok(beacon, "the preference must be sent, or direct_post returns no redirect_uri"); + assert.equal(beacon[1], ENDPOINT); + // sendBeacon takes a Blob, so the body is read back as text. + assert.deepEqual(JSON.parse(await beacon[2].text()), { + session_id: "s-1", + wallet_follows_redirect: true, + }, "the preference names THIS session; a cookie fallback would claim another tab's"); + assert.deepEqual(e.calls.map((c) => c[0]), ["sendBeacon", "navigate"], + "nothing may be awaited before a custom-scheme launch"); + }); + + it("leaves a modified click as an ordinary link", () => { + for (const key of ["metaKey", "ctrlKey", "shiftKey", "altKey"]) { + const link = { href: "openid4vp://?request_uri=x", onclick: null }; + const e = env(); + armWalletLink(link, "s-1", ORIGIN, e); + + const ev = click({ [key]: true }); + link.onclick(ev); + + assert.equal(ev.defaultPrevented, false, `${key} opens elsewhere; that is not this browser following the redirect`); + assert.deepEqual(e.calls, [], `${key} must not mark the session same-device`); + } + }); + + it("leaves a middle click alone", () => { + const link = { href: "openid4vp://?request_uri=x", onclick: null }; + const e = env(); + armWalletLink(link, "s-1", ORIGIN, e); + + const ev = click({ button: 1 }); + link.onclick(ev); + + assert.equal(ev.defaultPrevented, false); + assert.deepEqual(e.calls, []); + }); + + it("uses the href at CLICK time, not the one armed with", () => { + // The page arms the link and then a later reply can replace the + // href; sending the stale one would hand the wallet another + // session's request. + const link = { href: "openid4vp://?request_uri=first", onclick: null }; + const e = env(); + armWalletLink(link, "s-1", ORIGIN, e); + link.href = "openid4vp://?request_uri=second"; + + link.onclick(click()); + + assert.deepEqual(e.calls.filter((c) => c[0] === "navigate"), [["navigate", "openid4vp://?request_uri=second"]]); + }); + + it("confirms the preference first for a web wallet, which replaces this page", () => { + const link = { href: "https://wallet.example.com/request?x=1", onclick: null }; + const e = env(); + armWalletLink(link, "s-1", ORIGIN, e); + + link.onclick(click()); + + // The fetch never settles, so the navigation has provably not + // happened: a web wallet replaces this page, so a failed + // preference must still be recoverable. + assert.deepEqual(e.calls.map((c) => c[0]), ["openPlaceholder", "fetch"]); + }); +}); diff --git a/pkg/cache/authcontext_types.go b/pkg/cache/authcontext_types.go index 33a5b5a94..fee2c876f 100644 --- a/pkg/cache/authcontext_types.go +++ b/pkg/cache/authcontext_types.go @@ -155,6 +155,17 @@ 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"` + // 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. // diff --git a/pkg/model/config.go b/pkg/model/config.go index d80bc8fa2..4f49e4f59 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" 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 + // 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..1975ba8e8 --- /dev/null +++ b/pkg/openid4vp/ts12.go @@ -0,0 +1,143 @@ +package openid4vp + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" +) + +// 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 ( + // 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 { + // 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) == "" { + return fmt.Errorf("payee.name is required") + } + if strings.TrimSpace(p.Payee.ID) == "" { + return fmt.Errorf("payee.id is required") + } + 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") + } + 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 +} + +// 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_response.go b/pkg/openid4vp/ts12_response.go new file mode 100644 index 000000000..90273ae00 --- /dev/null +++ b/pkg/openid4vp/ts12_response.go @@ -0,0 +1,226 @@ +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", + }, +} + +// 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: present. Uniqueness is NOT checked here: recording it is a state + // change, and it must not happen before the issuer signature and trust + // have been established, or a fabricated credential could reserve chosen + // jti values. The caller records PresentedJTI once the presentation is + // authenticated. Entropy cannot be checked from here. + jti, ok := kb["jti"].(string) + if !ok || strings.TrimSpace(jti) == "" { + return txError("jti missing from KB-JWT") + } + v.PresentedJTI = jti + return nil +} diff --git a/pkg/openid4vp/ts12_response_test.go b/pkg/openid4vp/ts12_response_test.go new file mode 100644 index 000000000..a10bac016 --- /dev/null +++ b/pkg/openid4vp/ts12_response_test.go @@ -0,0 +1,404 @@ +package openid4vp + +import ( + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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 + 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() + _, err := c.run(t) + return err +} + +func (c ts12Case) run(t *testing.T) (*VPTokenValidator, 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) + 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, + } + return v, 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") +} + +// The allowed hash algorithms are the INTERSECTION of every scoped +// entry's list, not the first entry's. +// +// Every other multi-entry test here uses entries that carry identical +// defaults, so a regression that read only the first entry - or that +// unioned the lists instead of intersecting them - would pass all of +// them. These cases make the entries disagree on purpose, and check +// both directions: the algorithm inside the intersection is accepted, +// and every algorithm outside it is refused. A union bug would show up +// as an accepted algorithm only one entry allowed, which is a wallet +// hashing with something the other entry's issuer never agreed to. +func TestTS12Response_HashAlgIsTheIntersectionOfEveryEntry(t *testing.T) { + entry := func(t *testing.T, name string, algs ...string) string { + t.Helper() + obj := map[string]any{ + "type": "urn:eudi:sca:payment:1", + "credential_ids": []string{"pay"}, + // Distinct per entry, so the two are different strings and + // hash differently - an entry silently dropped shows up as + // a count mismatch rather than passing by coincidence. + "payload": map[string]any{"id": name}, + } + if algs != nil { + obj["transaction_data_hashes_alg"] = algs + } + raw, err := json.Marshal(obj) + require.NoError(t, err) + return base64.RawURLEncoding.EncodeToString(raw) + } + run := func(t *testing.T, alg string, entries ...string) error { + t.Helper() + hashes, err := HashTransactionData(entries, alg) + require.NoError(t, err) + return ts12Case{entries: entries, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": hashes, + "transaction_data_hashes_alg": alg, + }}.validate(t) + } + + t.Run("overlapping lists allow only what both named", func(t *testing.T) { + a := entry(t, "a", "sha-256", "sha-384") + b := entry(t, "b", "sha-384", "sha-512") + + require.NoError(t, run(t, "sha-384", a, b), "sha-384 is in both lists") + requireTxError(t, run(t, "sha-256", a, b), "not one the request allowed") + requireTxError(t, run(t, "sha-512", a, b), "not one the request allowed") + + // And the first entry alone really would have allowed sha-256, + // which is what makes the refusal above meaningful rather than + // a blanket rejection. + require.NoError(t, run(t, "sha-256", a)) + }) + + t.Run("disjoint lists allow nothing at all", func(t *testing.T) { + a := entry(t, "a", "sha-256") + b := entry(t, "b", "sha-512") + + requireTxError(t, run(t, "sha-256", a, b), "not one the request allowed") + requireTxError(t, run(t, "sha-512", a, b), "not one the request allowed") + requireTxError(t, run(t, "sha-384", a, b), "not one the request allowed") + }) + + t.Run("an absent list contributes the sha-256 default", func(t *testing.T) { + explicit := entry(t, "a", "sha-256", "sha-384") + absent := entry(t, "b") + + require.NoError(t, run(t, "sha-256", explicit, absent), + "the default is in the explicit list, so it survives the intersection") + requireTxError(t, run(t, "sha-384", explicit, absent), + "not one the request allowed") + }) + + t.Run("an explicit list without the default, paired with an absent one", func(t *testing.T) { + // The intersection is empty: one entry allows only sha-384, the + // other only the sha-256 default. Nothing the wallet can send + // is acceptable, and saying so is better than picking one. + explicit := entry(t, "a", "sha-384") + absent := entry(t, "b") + + requireTxError(t, run(t, "sha-384", explicit, absent), "not one the request allowed") + requireTxError(t, run(t, "sha-256", explicit, absent), "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") + + // The validator reports the jti for the caller to claim after issuer + // authentication; it never records it itself. + c = base() + c.claims["jti"] = "fixed-jti" + v, err := c.run(t) + require.NoError(t, err) + assert.Equal(t, "fixed-jti", v.PresentedJTI) + + // A refused presentation reports nothing to claim. + c = base() + c.claims["jti"] = "refused-jti" + c.claims["amr"] = nil + v, err = c.run(t) + require.Error(t, err) + assert.Empty(t, v.PresentedJTI) + + 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") +} + +// 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 +// replay store 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"]}`) + hs, _ := HashTransactionData([]string{raw}, "sha-256") + v, err := ts12Case{entries: []string{raw}, queryID: "pay", claims: map[string]any{ + "transaction_data_hashes": hs, "amr": nil, "jti": nil, "response_mode": nil, + }}.run(t) + require.NoError(t, err) + assert.Empty(t, v.PresentedJTI) +} + +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/ts12_test.go b/pkg/openid4vp/ts12_test.go new file mode 100644 index 000000000..7067687a5 --- /dev/null +++ b/pkg/openid4vp/ts12_test.go @@ -0,0 +1,154 @@ +package openid4vp + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "os" + "strings" + "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) + }) + } + + 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"}) + assert.Error(t, err, "unsupported alg refused") +} diff --git a/pkg/openid4vp/validator.go b/pkg/openid4vp/validator.go index 0e3d1fa12..69955d444 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,23 @@ 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 + + // PresentedJTI is an output: after a successful Validate of an EC TS12 + // SCA presentation it holds the KB-JWT jti. It is empty when no SCA entry + // applied. The caller must record it in a replay store after issuer + // authentication and treat a value already recorded as a refusal. + PresentedJTI string + // MaxKeyBindingAge bounds how old a Key Binding JWT's iat may be. // Zero means defaultMaxKeyBindingAge. MaxKeyBindingAge time.Duration @@ -72,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 replay store - 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() @@ -384,70 +413,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 }