diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..56926a1 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,161 @@ +# Implementation Plan: VCVerifier support for TIR v5 + +## Overview + +Extend VCVerifier to support EBSI Trusted Issuers Registry (TIR) v5 API alongside existing v3/v4 support. The v5 API returns attribute references (URLs) instead of inline attributes, requiring multi-step fetching (get issuer, list attributes with pagination, fetch each attribute). Configuration is extended with a `type` parameter on `TrustedIssuersLists` (mirroring the existing `TrustedParticipantsList` pattern): type `"ebsi"` selects v3/v4 auto-detection (default, backward-compatible), type `"ebsi-v5"` selects the v5 flow. + +## Steps + +### Step 1: Extend TrustedIssuersLists config type from []string to structured type + +**Goal:** Change `TrustedIssuersLists` from a plain `[]string` (URL-only) to a structured type with `Type` + `Url` fields, mirroring the existing `TrustedParticipantsList`/`TrustedParticipantsLists` pattern. All existing configs must continue to work unchanged (backward compatibility). + +**Files to modify:** + +- **`config/configClient.go`:** + - Create `TrustedIssuersList` struct with `Type string` and `Url string` fields (JSON/mapstructure tags), analogous to `TrustedParticipantsList` (line 197). + - Create `TrustedIssuersLists` type (slice of `TrustedIssuersList`) with a custom `UnmarshalJSON` method: try structured format first, fall back to plain string array (each string becomes `{Type: DEFAULT_LIST_TYPE, Url: url}`). This mirrors `TrustedParticipantsLists.UnmarshalJSON` (lines 204-232). + - Update the `Credential` struct field `TrustedIssuersLists` from `[]string` to `TrustedIssuersLists` (line 113). + +- **`verifier/credentialsConfig.go`:** + - Update `CredentialsConfig` interface: change `GetTrustedIssuersLists` return type from `[]string` to `[]config.TrustedIssuersList` (line 51). + - Update `cacheBasedCredentialsConfig.GetTrustedIssuersLists` implementation to return `[]config.TrustedIssuersList` (lines 287-299). + +- **`verifier/verifier.go`:** + - Update `TrustRegistriesValidationContext.trustedIssuersLists` field type from `map[string][]string` to `map[string][]configModel.TrustedIssuersList` (line 194). + - Update `GetTrustedIssuersLists()` return type accordingly (line 198). + - Update `getTrustRegistriesValidationContext` and `getTrustRegistriesValidationContextFromScope` to use the new type (lines 1125, 1139, 1142, 1189, 1217, 1220). + +- **`verifier/trustedissuer.go`:** + - Update `ValidateVC` to work with `[]TrustedIssuersList` instead of `[]string`. For backward compatibility in this step, extract URLs from entries where `Type == "ebsi"` (or empty/default) and pass them to the existing `tirClient.GetTrustedIssuer`. The `isWildcardTil` helper must be updated to check `Url` field instead of raw strings. + - Update `isWildcardTil` signature from `[]string` to `[]config.TrustedIssuersList`. + +- **`database/models.go`:** + - Update `CredentialDB.VO()` to construct `config.TrustedIssuersList{Type: listType, Url: endpoint}` entries instead of plain strings for `TrustedIssuers` endpoints (lines 286-287). Pass through `ListType` from `EndpointEntry`. + - Update `CredentialDB.FromVO()` to read `Type` and `Url` from `TrustedIssuersList` entries when building `EndpointEntry` values (lines 315-320). + +**Tests to add/update:** + +- **`config/configClient_test.go`:** Add tests for `TrustedIssuersLists` custom `UnmarshalJSON`: structured format, plain string array fallback, mixed validation. +- **`verifier/trustedissuer_test.go`:** Update `getVerificationContext()`, `getWildcardVerificationContext()`, `getInvalidMixedVerificationContext()`, and `getWildcardAndNormalVerificationContext()` helpers to construct `TrustedIssuersList` values instead of raw strings. +- **`verifier/trustedparticipant_test.go`:** Update any test helpers that construct `TrustRegistriesValidationContext` with `trustedIssuersLists`. +- **`database/models_test.go`:** Update round-trip tests for `CredentialDB.VO()`/`FromVO()` to verify `TrustedIssuersList` type and URL are preserved. + +**Acceptance criteria:** +- All existing YAML/JSON configs with `trustedIssuersLists: ["https://..."]` continue to work (parsed as `[{type: "ebsi", url: "https://..."}]`). +- New structured format `trustedIssuersLists: [{type: "ebsi-v5", url: "https://..."}]` is accepted. +- All existing tests pass (with updated test helpers). +- `go build ./...` succeeds with no compilation errors. + +--- + +### Step 2: Implement TIR v5 client methods in tir/tirClient.go + +**Goal:** Add v5 API support to the TIR HTTP client. The v5 API differs from v3/v4: `GET /v5/issuers/{did}` returns `{did, attributes: "", hasAttributes: true/false}` (attributes is a URL reference, not inline). Fetching attributes requires: `GET /v5/issuers/{did}/attributes` (paginated list of attribute IDs/hrefs) then `GET /v5/issuers/{did}/attributes/{id}` (individual attribute with the same `hash`/`body`/`issuerType`/`tao`/`rootTao` fields as v3/v4). The final result must be assembled into the existing `TrustedIssuer` struct for downstream compatibility. + +**Files to modify:** + +- **`tir/tirClient.go`:** + - Add path constant `ISSUERS_V5_PATH = "v5/issuers"`. + - Add v5-specific response structs: + - `TrustedIssuerV5Response` — `{Did string, Attributes string, HasAttributes bool}` (the `Attributes` field is a URL). + - `AttributesListV5Response` — paginated list: `{Items []AttributeListItem, Links PaginationLinks, Total int, PageSize int, Self string}`. + - `AttributeListItem` — `{ID string, Href string}`. + - `PaginationLinks` — `{First, Last, Next, Prev string}`. + - `AttributeV5Response` — single attribute: `{Attribute IssuerAttribute, Did string}`. + - Add `getIssuerV5Url(did string) string` helper returning `ISSUERS_V5_PATH + "/" + did`. + - Add `getAttributesV5Url(did string) string` helper returning `ISSUERS_V5_PATH + "/" + did + "/attributes"`. + - Extend the `TirClient` interface with two new methods: + - `IsTrustedParticipantV5(tirEndpoint string, did string) bool` — calls `GET /v5/issuers/{did}`, returns true if 200. + - `GetTrustedIssuerV5(tirEndpoints []string, did string) (bool, TrustedIssuer, error)` — for each endpoint: (1) `GET /v5/issuers/{did}`, (2) if `hasAttributes`, follow pagination on the attributes URL to collect all attribute IDs, (3) fetch each attribute individually, (4) assemble into `TrustedIssuer{Did, Attributes}`. Cache the assembled result in `tirCache`/`tilCache`. + - Implement pagination loop for `AttributesListV5Response`: follow `Links.Next` until empty or equal to current page. + - Reuse existing `HttpGetClient` interface for all HTTP calls. + +**Tests to add:** + +- **`tir/tirClient_test.go`:** + - Add table-driven tests for `IsTrustedParticipantV5`: mock HTTP responses for 200 (found), 404 (not found), network error. + - Add table-driven tests for `GetTrustedIssuerV5`: mock multi-step flow (issuer response → attributes list → individual attributes), verify assembled `TrustedIssuer` matches expected. Include cases for: single attribute, multiple attributes, pagination (multiple pages), issuer with `hasAttributes: false`, issuer not found (404), attribute fetch error (partial failure). + - Test caching behavior: second call for same DID should hit cache. + +**Acceptance criteria:** +- `TirClient` interface has `IsTrustedParticipantV5` and `GetTrustedIssuerV5` methods. +- V5 multi-step attribute fetching correctly assembles a `TrustedIssuer` with all attributes. +- Pagination is handled (follows `next` links). +- Results are cached using existing `tirCache`/`tilCache`. +- All new and existing tests pass. + +--- + +### Step 3: Wire v5 dispatch in verifier layer (trustedissuer.go and trustedparticipant.go) + +**Goal:** Route "ebsi-v5" typed entries to the new v5 TIR client methods. The existing "ebsi" type continues to use v3/v4 auto-detection. The dispatch pattern follows the existing `trustedparticipant.go` model (type-based `if` checks). + +**Files to modify:** + +- **`verifier/trustedparticipant.go`:** + - Add constant `typeEbsiV5 = "ebsi-v5"` alongside existing `typeGaiaX` and `typeEbsi` (line 17). + - In `ValidateVC`, add a new dispatch branch: `if participantList.Type == typeEbsiV5` calls `tpvs.tirClient.IsTrustedParticipantV5(participantList.Url, ...)` (after line 57). + +- **`verifier/trustedissuer.go`:** + - Add constant `typeEbsiV5 = "ebsi-v5"` and `typeEbsi = "ebsi"`. + - Refactor `ValidateVC` to dispatch based on `TrustedIssuersList.Type`: + - For entries with type `"ebsi"` (or empty/default): collect URLs into a `[]string` slice and call `tpvs.tirClient.GetTrustedIssuer(urls, did)` (preserving existing v3/v4 behavior). + - For entries with type `"ebsi-v5"`: collect URLs into a `[]string` slice and call `tpvs.tirClient.GetTrustedIssuerV5(urls, did)`. + - The wildcard check (`isWildcardTil`) should apply across all entries regardless of type. + - Ensure that if a credential type has a mix of "ebsi" and "ebsi-v5" entries, both are tried (ebsi entries via v3/v4, ebsi-v5 entries via v5), and the first successful match wins. + +**Tests to add/update:** + +- **`verifier/trustedparticipant_test.go`:** + - Add test cases for "ebsi-v5" type: participant found via v5, participant not found via v5, mixed ebsi + ebsi-v5 lists. + - Update mock `TirClient` to implement `IsTrustedParticipantV5`. + +- **`verifier/trustedissuer_test.go`:** + - Add test cases for "ebsi-v5" type: issuer found via v5, issuer not found via v5. + - Add test case for mixed types: one "ebsi" entry and one "ebsi-v5" entry for the same credential type. + - Update mock `TirClient` to implement `GetTrustedIssuerV5`. + - Update existing test helper functions to use `TrustedIssuersList` struct with explicit `Type: "ebsi"`. + +**Acceptance criteria:** +- "ebsi-v5" type in `TrustedParticipantsList` routes to `IsTrustedParticipantV5`. +- "ebsi-v5" type in `TrustedIssuersLists` routes to `GetTrustedIssuerV5`. +- "ebsi" (and empty/default) type continues to use v3/v4 auto-detection. +- Mixed type lists work correctly. +- All tests pass. + +--- + +### Step 4: End-to-end verification, test fixtures, and documentation + +**Goal:** Add integration-level test scenarios covering the complete flow from config parsing through TIR v5 verification. Update test fixture files and config examples. Ensure all tests pass and the build is clean. + +**Files to modify/add:** + +- **`config/data/`:** Add or update YAML/JSON test fixtures: + - A fixture with `trustedIssuersLists` in the new structured format (type "ebsi-v5"). + - A fixture with mixed old-format (string array) and new-format entries to verify backward compatibility. + - A fixture with `trustedParticipantsLists` including "ebsi-v5" type entry. + +- **`verifier/verifier_test.go`:** + - Add or update integration-level tests that exercise the full `AuthenticationResponse` or `GenerateToken` flow with "ebsi-v5" configured trust registries (using mocked TIR client). + - Verify that `getTrustRegistriesValidationContext` correctly propagates type information from config through to the validation context. + +- **`database/models_test.go`:** + - Add round-trip tests for `CredentialDB` with "ebsi-v5" typed trusted issuers lists: `FromVO()` → `VO()` preserves type and URL. + +- **`config/configClient_test.go`:** + - Add test for `ReadConfig()` / config parsing with the new structured `trustedIssuersLists` format in YAML. + - Verify that YAML `trustedIssuersLists: ["https://url"]` still parses correctly (backward compat via mapstructure). + +**Verification steps:** +- Run `go build ./...` — must succeed with no errors. +- Run `go vet ./...` — must succeed with no warnings. +- Run `go test ./... -v` — all tests must pass. +- Run `go test ./... -v -coverprofile=profile.cov` to verify coverage of new code paths. + +**Acceptance criteria:** +- All new and existing tests pass. +- Config backward compatibility is verified by tests (old `[]string` format, new structured format, database round-trip). +- The full verification chain works end-to-end with "ebsi-v5" configuration. +- No compilation warnings or vet issues. diff --git a/api/credentials-config.yaml b/api/credentials-config.yaml new file mode 100644 index 0000000..e3f09ec --- /dev/null +++ b/api/credentials-config.yaml @@ -0,0 +1,642 @@ +openapi: 3.0.3 +info: + title: Credentials configuration service + description: Provides and manages the scopes to be requested and the trust-anchors(trusted-issuers-list and trusted-participants-list) per service/credential. + version: 0.0.1 +tags: + - name: service +paths: + /service: + post: + tags: + - service + operationId: createService + summary: Create a service with its credentials configuration + description: Create a service with the given configuration. If no id is provided, the service will generate one. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + responses: + '201': + description: Successfully created the service. + headers: + Location: + schema: + type: string + format: uri + example: /service/packet-delivery-service + description: Location of the created service + '400': + description: Invalid service provided + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + '409': + description: Service with the given id already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + get: + tags: + - service + parameters: + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/PageNumber' + operationId: getServices + summary: Return all services + description: Return all services configured, with pagination. + responses: + '200': + description: The service config. + content: + application/json: + schema: + $ref: '#/components/schemas/Services' + '400': + description: Invalid query parameters provided + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + /service/{id}: + get: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + operationId: getService + summary: Return the full service config by ID + description: The service configuration, including all credentials and their trust anchors will be returned. + responses: + '200': + description: The service config + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '404': + description: No such service exists. + delete: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + operationId: deleteServiceById + description: Delete a single service(and all its configurations) with the given id. + summary: Delete the service + responses: + '204': + description: Successfully deleted + '404': + description: No such service exists. + put: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + summary: Update a single service + description: Updates a single service by fully overriding it. + operationId: updateService + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + responses: + '200': + description: Successfully updated the service. + content: + application/json: + schema: + $ref: '#/components/schemas/Service' + '404': + description: No such service exists. + '400': + description: Invalid service provided + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' + /service/{id}/scope: + get: + tags: + - service + parameters: + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/OidcScope' + operationId: getScopeForService + summary: Get the scope for the service + description: Returns the scope(e.g. credential types to be requested) for the requested service + responses: + '200': + description: The scopes to be requested for the service + content: + application/json: + schema: + $ref: '#/components/schemas/Scope' + '404': + description: No such service exists. + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetails' +components: + parameters: + Id: + name: id + in: path + required: true + schema: + type: string + example: packet-delivery-service + OidcScope: + name: oidcScope + in: query + required: false + schema: + type: string + example: did_read + PageSize: + name: pageSize + in: query + schema: + type: integer + default: 100 + minimum: 1 + PageNumber: + name: page + in: query + schema: + type: integer + default: 0 + minimum: 0 + schemas: + Scope: + type: array + description: A list of credential-types to be requested + items: + type: string + example: + - VerifiableCredential + - CustomerCredential + Credential: + type: object + description: A credential-type with its trust configuration + properties: + type: + type: string + description: Type of the credential + example: VerifiableCredential + trustedParticipantsLists: + type: array + description: | + A list of trusted participants registry endpoints. Each entry is + either a structured object with type and url, or a plain URL string. + For backward-compatibility, plain URL strings are interpreted as + EBSI endpoints. + items: + $ref: '#/components/schemas/TrustedParticipantsListEndpoint' + example: + - type: ebsi + url: https://my-ebsi.list + - type: gaia-x + url: https://my-gaia-x.registry + trustedIssuersLists: + type: array + description: | + A list of trusted issuers registry endpoints. Each entry is either + a structured object with type and url, or a plain URL string. For + backward-compatibility, plain URL strings are interpreted as EBSI + (v3/v4) endpoints. Use type "ebsi-v5" for the TIR v5 API. + items: + $ref: '#/components/schemas/TrustedIssuersListEndpoint' + example: + - type: ebsi + url: https://til-pdc.gaia-x.fiware.dev + - type: ebsi-v5 + url: https://til-v5.example.com + holderVerification: + $ref: '#/components/schemas/HolderVerification' + requireCompliance: + type: boolean + default: false + description: Does the given credential require a compliancy credential + jwtInclusion: + $ref: '#/components/schemas/JwtInclusion' + required: + - type + JwtInclusion: + type: object + description: Configuration for the credential to decide its inclusion into the JWT. + properties: + enabled: + type: boolean + default: true + description: Should the given credential be included into the generated JWT + fullInclusion: + type: boolean + default: false + description: Should the complete credential be embedded + claimsToInclude: + type: array + description: Claims to be included + items: + $ref: '#/components/schemas/Claim' + Claim: + type: object + description: Claim of the a credential to be included in the JWT. + properties: + originalKey: + type: string + description: Key of the claim to be included. All objects under this key will be included unchanged. + newKey: + type: string + description: Key of the claim to be used in the jwt. If not provided, the original one will be used. + required: + - originalKey + TrustedParticipantsListEndpoint: + type: object + description: Endpoint of the trusted participants list and its type + properties: + type: + type: string + enum: + - gaia-x + - ebsi + - ebsi-v5 + url: + type: string + format: url + example: https://tir-pdc.gaia-x.fiware.dev + TrustedIssuersListEndpoint: + type: object + description: Endpoint of the trusted issuers list and its type. Use "ebsi" for v3/v4 auto-detection, "ebsi-v5" for the v5 API. + properties: + type: + type: string + enum: + - ebsi + - ebsi-v5 + url: + type: string + format: url + example: https://til-pdc.gaia-x.fiware.dev + HolderVerification: + type: object + description: Configuration of holder verification for the given credential type + properties: + enabled: + type: boolean + description: Should the holder verification be enabled + default: false + claim: + type: string + description: Name of the claim containing the holder id + default: subject + required: + - enabled + - claim + ServiceScopesEntry: + type: object + properties: + credentials: + type: array + description: Trust configuration for the credentials + minItems: 1 + items: + $ref: '#/components/schemas/Credential' + presentationDefinition: + $ref: '#/components/schemas/PresentationDefinition' + nullable: true + dcql: + $ref: '#/components/schemas/DCQL' + nullable: true + flatClaims: + type: boolean + default: false + description: When set, the claim are flatten to plain JWT-claims before beeing included, instead of keeping the credential/presentation structure, where the claims are under the key vc or vp + required: + - credentials + Service: + type: object + description: Configuration of a service and its credentials + properties: + id: + type: string + description: Id of the service to be configured. If no id is provided, the service will generate one. + example: packet-delivery-service + defaultOidcScope: + type: string + description: Default OIDC scope to be used if none is specified + example: default + oidcScopes: + type: object + description: A specific OIDC scope for that service, specifying the necessary VC types (credentials) + additionalProperties: + $ref: '#/components/schemas/ServiceScopesEntry' + authorizationType: + type: string + description: The authorization redirect to be created. + default: FRONTEND_V2 + enum: + - FRONTEND_V2 + - DEEPLINK + required: + - oidcScopes + - defaultOidcScope + Services: + type: object + description: The paginated list of services + properties: + total: + type: integer + description: Total number of services available + example: 25 + pageNumber: + type: integer + description: Number of the page to be retrieved. + example: 0 + pageSize: + type: integer + description: Size of the returend page, can be less than the requested depending on the available entries + example: 10 + services: + type: array + description: The list of services + items: + $ref: '#/components/schemas/Service' + DCQL: + type: object + description: JSON encoded query to request the credentials to be included in the presentation + properties: + credentials: + type: array + description: A non-empty array of Credential Queries that specify the requested Credentials. + items: + $ref: '#/components/schemas/CredentialQuery' + credential_sets: + type: array + description: A non-empty array of Credential Set Queries that specifies additional constraints on which of the requested Credentials to return. + items: + $ref: '#/components/schemas/CredentialSetQuery' + required: + - credentials + CredentialQuery: + type: object + description: A Credential Query is an object representing a request for a presentation of one or more matching Credentials + properties: + id: + type: string + description: A string identifying the Credential in the response and, if provided, the constraints in credential_sets. The value MUST be a non-empty string consisting of alphanumeric, underscore (_), or hyphen (-) characters. Within the Authorization Request, the same id MUST NOT be present more than once. + example: my-credential-query-id + format: + type: string + description: A string that specifies the format of the requested Credential. + enum: + - mso_mdoc + - vc+sd-jwt + - dc+sd-jwt + - ldp_vc + - jwt_vc_json + example: jwt_vc_json + multiple: + type: boolean + default: false + description: A boolean which indicates whether multiple Credentials can be returned for this Credential Query. If omitted, the default value is false. + example: false + claims: + type: array + description: A non-empty array of objects that specifies claims in the requested Credential. Verifiers MUST NOT point to the same claim more than once in a single query. Wallets SHOULD ignore such duplicate claim queries. + items: + $ref: '#/components/schemas/ClaimsQuery' + meta: + $ref: '#/components/schemas/MetaDataQuery' + require_cryptographic_holder_binding: + type: boolean + default: true + description: A boolean which indicates whether the Verifier requires a Cryptographic Holder Binding proof. The default value is true, i.e., a Verifiable Presentation with Cryptographic Holder Binding is required. If set to false, the Verifier accepts a Credential without Cryptographic Holder Binding proof. + example: true + claim_sets: + type: array + description: A non-empty array containing arrays of identifiers for elements in claims that specifies which combinations of claims for the Credential are requested. + items: + $ref: '#/components/schemas/ClaimSet' + trusted_authorities: + type: array + description: A non-empty array of objects that specifies expected authorities or trust frameworks that certify Issuers, that the Verifier will accept. Every Credential returned by the Wallet SHOULD match at least one of the conditions present in the corresponding trusted_authorities array if present. + items: + $ref: '#/components/schemas/TrustedAuthorityQuery' + ClaimsQuery: + type: object + description: A query to specifies claims in the requested Credential. + properties: + id: + type: string + description: REQUIRED if claim_sets is present in the Credential Query; OPTIONAL otherwise. A string identifying the particular claim. The value MUST be a non-empty string consisting of alphanumeric, underscore (_), or hyphen (-) characters. Within the particular claims array, the same id MUST NOT be present more than once. + example: my-claim-query-id + path: + type: array + description: The value MUST be a non-empty array representing a claims path pointer that specifies the path to a claim within the Credential. See https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-claims-path-pointer + items: + type: object + example: ["path", "to", "claim"] + values: + type: array + description: A non-empty array of strings, integers or boolean values that specifies the expected values of the claim. If the values property is present, the Wallet SHOULD return the claim only if the type and value of the claim both match exactly for at least one of the elements in the array. + items: + type: object + example: ["supported-value-1", "supported-value-2"] + intent_to_retain: + type: boolean + description: MDoc specific parameter, ignored for all other types. The flag can be set to inform that the reader wishes to keep(store) the data. In case of false, its data is only used to be dispalyed and verified. + example: false + namespace: + type: string + description: MDoc specific parameter, ignored for all other types. Refers to a namespace inside an mdoc. + example: "org.iso.7367.1" + claim_name: + type: string + description: MDoc specific parameter, ignored for all other types. Identifier for the data-element in the namespace. + example: "first_name" + MetaDataQuery: + type: object + description: Defines additional properties requested by the Verifier that apply to the metadata and validity data of the Credential. The properties of this object are defined per Credential Format. If empty, no specific constraints are placed on the metadata or validity of the requested Credential. + properties: + vct_values: + type: array + description: SD-JWT and JWT specific parameter. A non-empty array of strings that specifies allowed values for the type of the requested Verifiable Credential.The Wallet MAY return Credentials that inherit from any of the specified types, following the inheritance logic defined in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-sd-jwt-vc-10 + items: + type: string + doctype_value: + type: string + description: Required for MDoc. String that specifies an allowed value for the doctype of the requested Verifiable Credential. It MUST be a valid doctype identifier as defined in https://www.iso.org/standard/69084.html + example: "org.iso.7367.1.mVRC" + type_values: + type: array + description: Required for ldp_vc. A non-empty array of string arrays. The Type value of the credential needs to be a subset of at least one of the string-arrays. + items: + type: array + items: + type: string + ClaimSet: + type: array + description: An array contain identifiers of elements in the claims, that specifies wich combination of claims is requested + items: + type: string + example: ["claim-id-a","claim-id-b"] + TrustedAuthorityQuery: + type: object + description: An object representing information that helps to identify an authority or the trust framework that certifies Issuers. A Credential is identified as a match to a Trusted Authorities Query if it matches with one of the provided values in one of the provided types. + properties: + type: + type: string + description: A string uniquely identifying the type of information about the issuer trust framework. + - aki + - etsi_tl + - openid_federation + example: "aki" + values: + type: array + description: A non-empty array of strings, where each string (value) contains information specific to the used Trusted Authorities Query type that allows the identification of an issuer, a trust framework, or a federation that an issuer belongs to. + items: + type: string + example: ["s9tIpPmhxdiuNkHMEWNpYim8S8Y"] + required: + - type + - values + CredentialSetQuery: + type: object + description: A Credential Set Query is an object representing a request for one or more Credentials to satisfy a particular use case with the Verifier. + properties: + options: + type: array + description: A non-empty array, where each value in the array is a list of Credential Query identifiers representing one set of Credentials that satisfies the use case. The value of each element in the options array is a non-empty array of identifiers which reference elements in credentials. + items: + type: array + items: + type: string + required: + type: boolean + description: A boolean which indicates whether this set of Credentials is required to satisfy the particular use case at the Verifier. + default: true + example: true + purpose: + type: object + description: A string, number or object specifying the purpose of the query. This specification does not define a specific structure or specific values for this property. The purpose is intended to be used by the Verifier to communicate the reason for the query to the Wallet. The Wallet MAY use this information to show the user the reason for the request. + example: "Identification" + PresentationDefinition: + type: object + description: Proofs required by the service - see https://identity.foundation/presentation-exchange/#presentation-definition + properties: + id: + type: string + description: Id of the definition + example: "32f54163-7166-48f1-93d8-ff217bdb0653" + name: + type: string + description: A human readable name for the definition + example: My default service credentials + purpose: + type: string + description: A string that describes the purpose for wich the definition should be used + example: The service requires age and name of the requesting user. + input_descriptors: + type: array + description: List of requested inputs for the presentation + items: + $ref: '#/components/schemas/InputDescriptor' + format: + $ref: '#/components/schemas/Format' + required: + - id + - input_descriptors + InputDescriptor: + type: object + properties: + id: + type: string + description: Id of the descriptor + example: "32f54163-7166-48f1-93d8-ff217bdb0653" + name: + type: string + description: A human readable name for the definition + example: User Age request + purpose: + type: string + description: A string that describes the purpose for which the claim is requested + example: Only users above a certain age should get service access + constraints: + $ref: '#/components/schemas/Constraints' + format: + $ref: '#/components/schemas/Format' + required: + - id + - constraints + Constraints: + type: object + properties: + fields: + type: array + description: List of the requested claims + items: + $ref: '#/components/schemas/Field' + Field: + type: object + properties: + id: + type: string + description: Id of the field + example: "32f54163-7166-48f1-93d8-ff217bdb0653" + name: + type: string + description: A human readable name for the definition + example: User Age request + purpose: + type: string + description: A string that describes the purpose for which the claim is requested + example: Only users above a certain age should get service access + optional: + type: boolean + description: Defines if the described field is considered optional or not + path: + type: array + description: An array of JsonPaths that selects the value from the input + items: + type: string + example: "$.credentialSubject.dateOfBirth" + filter: + type: object + description: Filter to be evaluated against the values returned from path evaluation + Format: + type: object + additionalProperties: true + ProblemDetails: + type: object + properties: + type: + description: An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable documentation for the problem type. + type: string + format: uri + ##default: about:blank + title: + description: A short summary of the problem type. + type: string + example: Internal Server Error + status: + description: The HTTP status code generated by the origin server for this occurrence of the problem. + type: integer + example: 500 + detail: + description: A human readable explanation specific to this occurrence of the problem. + type: string + example: Connection timeout + instance: + description: An absolute URI that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. + type: string + format: uri diff --git a/config/configClient.go b/config/configClient.go index 0724e29..02164ce 100644 --- a/config/configClient.go +++ b/config/configClient.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "net/http" + "reflect" "strings" "github.com/fiware/VCVerifier/logging" + "github.com/mitchellh/mapstructure" ) type EndpointType int @@ -110,7 +112,7 @@ type Credential struct { // A list of (EBSI Trusted Issuers Registry compatible) endpoints to retrieve the trusted participants from. TrustedParticipantsLists TrustedParticipantsLists `json:"trustedParticipantsLists,omitempty" mapstructure:"trustedParticipantsLists,omitempty"` // A list of (EBSI Trusted Issuers Registry compatible) endpoints to retrieve the trusted issuers from. The attributes need to be formatted to comply with the verifiers requirements. - TrustedIssuersLists []string `json:"trustedIssuersLists,omitempty" mapstructure:"trustedIssuersLists,omitempty"` + TrustedIssuersLists TrustedIssuersLists `json:"trustedIssuersLists,omitempty" mapstructure:"trustedIssuersLists,omitempty"` // Configuration of Holder Verification HolderVerification HolderVerification `json:"holderVerification" mapstructure:"holderVerification"` // Does the given credential require a compliancy credential @@ -231,6 +233,101 @@ func (t *TrustedParticipantsLists) UnmarshalJSON(data []byte) error { return nil } +// TrustedIssuersList represents a single trusted issuers registry endpoint +// with an associated type (e.g. "ebsi", "ebsi-v5"). Mirrors +// TrustedParticipantsList for issuers. +type TrustedIssuersList struct { + // Type of issuers list to be used — "ebsi" for v3/v4, "ebsi-v5" for v5. + Type string `json:"type" mapstructure:"type"` + // Url of the trusted issuers registry endpoint. + Url string `json:"url" mapstructure:"url"` +} + +// TrustedIssuersLists is a slice of TrustedIssuersList with a custom JSON +// unmarshaler that accepts both the new structured format and the legacy +// plain string array format for backward compatibility. +type TrustedIssuersLists []TrustedIssuersList + +// UnmarshalJSON supports two JSON formats: +// - Structured: [{"type":"ebsi-v5","url":"https://..."}] +// - Legacy string array: ["https://..."] — each URL defaults to type "ebsi". +func (t *TrustedIssuersLists) UnmarshalJSON(data []byte) error { + // Try structured format first + var structured []TrustedIssuersList + if err := json.Unmarshal(data, &structured); err == nil { + *t = structured + return nil + } + + // Fallback to string array format + var urls []string + if err := json.Unmarshal(data, &urls); err != nil { + return err + } + + result := make([]TrustedIssuersList, len(urls)) + + for i, url := range urls { + result[i] = TrustedIssuersList{ + Type: DEFAULT_LIST_TYPE, + Url: url, + } + } + + *t = result + + return nil +} + +// trustedIssuersListsType is the reflect.Type for TrustedIssuersLists, cached +// to avoid repeated reflect calls in the decode hook. +var trustedIssuersListsType = reflect.TypeOf(TrustedIssuersLists{}) + +// TrustedIssuersListsDecodeHook returns a mapstructure DecodeHookFuncType that +// converts a legacy plain-string slice (from YAML) into a TrustedIssuersLists +// value. Each bare URL string becomes a TrustedIssuersList entry with the +// default type ("ebsi"). Structured entries (maps) are decoded inline. +// This mirrors the JSON backward-compatibility provided by UnmarshalJSON but +// for the YAML/mapstructure code path. +func TrustedIssuersListsDecodeHook() mapstructure.DecodeHookFuncType { + return func(from reflect.Type, to reflect.Type, data interface{}) (interface{}, error) { + if to != trustedIssuersListsType { + return data, nil + } + + slice, ok := data.([]interface{}) + if !ok { + return data, nil + } + + result := make(TrustedIssuersLists, 0, len(slice)) + for _, item := range slice { + switch v := item.(type) { + case string: + // Legacy plain-URL format → default type. + result = append(result, TrustedIssuersList{ + Type: DEFAULT_LIST_TYPE, + Url: v, + }) + case map[string]interface{}: + // Structured format — extract type and url. + entry := TrustedIssuersList{} + if t, ok := v["type"]; ok { + entry.Type = fmt.Sprintf("%v", t) + } + if u, ok := v["url"]; ok { + entry.Url = fmt.Sprintf("%v", u) + } + result = append(result, entry) + default: + // Unrecognised element — let mapstructure surface an error. + return data, nil + } + } + return result, nil + } +} + // EndpointEntry describes a single trust-registry endpoint together with its // type and the list format it exposes. type EndpointEntry struct { diff --git a/config/configClient_test.go b/config/configClient_test.go index 941ab2a..c01874e 100644 --- a/config/configClient_test.go +++ b/config/configClient_test.go @@ -221,7 +221,7 @@ func Test_getServices(t *testing.T) { { Type: "VerifiableCredential", TrustedParticipantsLists: []TrustedParticipantsList{{Type: "ebsi", Url: "https://tir-pdc.ebsi.fiware.dev"}}, - TrustedIssuersLists: []string{"https://til-pdc.ebsi.fiware.dev"}, + TrustedIssuersLists: TrustedIssuersLists{{Type: "ebsi", Url: "https://til-pdc.ebsi.fiware.dev"}}, HolderVerification: HolderVerification{Enabled: false, Claim: "subject"}, }, }, @@ -262,3 +262,173 @@ func Test_getServices(t *testing.T) { } assert.Equal(t, expectedScopesVO, scopesVO) } + +// Test_getServicesV5 verifies that the CCS HTTP client correctly parses a JSON +// response containing "ebsi-v5" typed TrustedIssuersLists and +// TrustedParticipantsLists entries. +func Test_getServicesV5(t *testing.T) { + mockedHttpClient := MockHttpClient{readFile("ccs_v5.json", t)} + ccsClient := HttpConfigClient{mockedHttpClient, "test.com"} + services, err := ccsClient.GetServices() + if err != nil { + t.Error("should not return error", err) + } + assert.NotEmpty(t, services) + assert.Len(t, services, 1) + + svc := services[0] + assert.Equal(t, "service_v5", svc.Id) + assert.Equal(t, "v5_scope", svc.DefaultOidcScope) + + scopesVO := svc.ServiceScopes + creds := scopesVO["v5_scope"].Credentials + assert.Len(t, creds, 1) + + cred := creds[0] + assert.Equal(t, "VerifiableCredential", cred.Type) + + // Verify TrustedParticipantsLists with ebsi-v5 type + assert.Len(t, cred.TrustedParticipantsLists, 1) + assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[0].Type) + assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url) + + // Verify mixed TrustedIssuersLists: ebsi-v5 + ebsi + assert.Len(t, cred.TrustedIssuersLists, 2) + assert.Equal(t, "ebsi-v5", cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://til-v5.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url) + assert.Equal(t, "ebsi", cred.TrustedIssuersLists[1].Type) + assert.Equal(t, "https://til-pdc.ebsi.fiware.dev", cred.TrustedIssuersLists[1].Url) +} + +func TestTrustedIssuersLists_UnmarshalJSON(t *testing.T) { + type testCase struct { + name string + input string + expected TrustedIssuersLists + wantErr bool + } + + tests := []testCase{ + { + name: "structured format with explicit types", + input: `[{"type":"ebsi-v5","url":"https://v5.example.com"},{"type":"ebsi","url":"https://v3.example.com"}]`, + expected: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + {Type: "ebsi", Url: "https://v3.example.com"}, + }, + }, + { + name: "legacy plain string array", + input: `["https://tir-a.example.com","https://tir-b.example.com"]`, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-a.example.com"}, + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-b.example.com"}, + }, + }, + { + name: "empty array", + input: `[]`, + expected: TrustedIssuersLists{}, + }, + { + name: "single legacy string", + input: `["https://only.example.com"]`, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://only.example.com"}, + }, + }, + { + name: "single structured entry", + input: `[{"type":"ebsi-v5","url":"https://only-v5.example.com"}]`, + expected: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://only-v5.example.com"}, + }, + }, + { + name: "invalid JSON", + input: `not-json`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got TrustedIssuersLists + err := json.Unmarshal([]byte(tc.input), &got) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestTrustedIssuersListsDecodeHook(t *testing.T) { + hook := TrustedIssuersListsDecodeHook() + + type testCase struct { + name string + input interface{} + expected interface{} + } + + tests := []testCase{ + { + name: "legacy plain string slice", + input: []interface{}{ + "https://tir-a.example.com", + "https://tir-b.example.com", + }, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-a.example.com"}, + {Type: DEFAULT_LIST_TYPE, Url: "https://tir-b.example.com"}, + }, + }, + { + name: "structured map entries", + input: []interface{}{ + map[string]interface{}{"type": "ebsi-v5", "url": "https://v5.example.com"}, + map[string]interface{}{"type": "ebsi", "url": "https://v3.example.com"}, + }, + expected: TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + {Type: "ebsi", Url: "https://v3.example.com"}, + }, + }, + { + name: "mixed strings and maps", + input: []interface{}{ + "https://legacy.example.com", + map[string]interface{}{"type": "ebsi-v5", "url": "https://v5.example.com"}, + }, + expected: TrustedIssuersLists{ + {Type: DEFAULT_LIST_TYPE, Url: "https://legacy.example.com"}, + {Type: "ebsi-v5", Url: "https://v5.example.com"}, + }, + }, + { + name: "empty slice", + input: []interface{}{}, + expected: TrustedIssuersLists{}, + }, + } + + targetType := reflect.TypeOf(TrustedIssuersLists{}) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := hook(reflect.TypeOf(tc.input), targetType, tc.input) + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } + + t.Run("passthrough for non-target type", func(t *testing.T) { + input := []interface{}{"https://example.com"} + result, err := hook(reflect.TypeOf(input), reflect.TypeOf(""), input) + assert.NoError(t, err) + // Should return the input unchanged since target type doesn't match. + assert.Equal(t, input, result) + }) +} diff --git a/config/data/ccs_v5.json b/config/data/ccs_v5.json new file mode 100644 index 0000000..2f78f4f --- /dev/null +++ b/config/data/ccs_v5.json @@ -0,0 +1,36 @@ +{ + "total": 1, + "pageNumber": 0, + "pageSize": 100, + "services": [ + { + "id": "service_v5", + "defaultOidcScope": "v5_scope", + "oidcScopes": { + "v5_scope": { + "credentials": [ + { + "type": "VerifiableCredential", + "trustedParticipantsLists": [ + { + "type": "ebsi-v5", + "url": "https://tir-v5.ebsi.fiware.dev" + } + ], + "trustedIssuersLists": [ + { + "type": "ebsi-v5", + "url": "https://til-v5.ebsi.fiware.dev" + }, + { + "type": "ebsi", + "url": "https://til-pdc.ebsi.fiware.dev" + } + ] + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/config/data/config_test_mixed.yaml b/config/data/config_test_mixed.yaml new file mode 100644 index 0000000..9c44c8a --- /dev/null +++ b/config/data/config_test_mixed.yaml @@ -0,0 +1,42 @@ +server: + port: 3000 + staticDir: "views/static" + templateDir: "views/" + +logging: + level: "DEBUG" + jsonLogging: true + logRequests: true + +verifier: + did: "did:key:somekey" + tirAddress: "https://test.dev/trusted_issuer/v3/issuers/" + +m2m: + authEnabled: false + +configRepo: + services: + - id: testServiceMixed + defaultOidcScope: mixedScope + oidcScopes: + mixedScope: + credentials: + - type: VerifiableCredential + trustedParticipantsLists: + - type: ebsi + url: https://tir-pdc.ebsi.fiware.dev + - type: ebsi-v5 + url: https://tir-v5.ebsi.fiware.dev + # Legacy string array format for backward compatibility. + trustedIssuersLists: + - https://til-pdc.ebsi.fiware.dev + presentationDefinition: + id: mixed-pd + input_descriptors: + - id: mixed-descriptor + constraints: + fields: + - id: mixed-field + path: + - $.vc.my.claim diff --git a/config/data/config_test_v5.yaml b/config/data/config_test_v5.yaml new file mode 100644 index 0000000..67579cf --- /dev/null +++ b/config/data/config_test_v5.yaml @@ -0,0 +1,40 @@ +server: + port: 3000 + staticDir: "views/static" + templateDir: "views/" + +logging: + level: "DEBUG" + jsonLogging: true + logRequests: true + +verifier: + did: "did:key:somekey" + tirAddress: "https://test.dev/trusted_issuer/v3/issuers/" + +m2m: + authEnabled: false + +configRepo: + services: + - id: testServiceV5 + defaultOidcScope: v5Scope + oidcScopes: + v5Scope: + credentials: + - type: VerifiableCredential + trustedParticipantsLists: + - type: ebsi-v5 + url: https://tir-v5.ebsi.fiware.dev + trustedIssuersLists: + - type: ebsi-v5 + url: https://til-v5.ebsi.fiware.dev + presentationDefinition: + id: v5-pd + input_descriptors: + - id: v5-descriptor + constraints: + fields: + - id: v5-field + path: + - $.vc.my.claim diff --git a/config/provider.go b/config/provider.go index 2386a7d..c00dff7 100644 --- a/config/provider.go +++ b/config/provider.go @@ -6,6 +6,7 @@ import ( "github.com/gookit/config/v2" "github.com/gookit/config/v2/yaml" + "github.com/mitchellh/mapstructure" ) // read the config from the config file @@ -14,6 +15,17 @@ func ReadConfig(configFile string) (configuration Configuration, err error) { opt.ParseDefault = true opt.ParseEnv = true opt.TagName = "mapstructure" + // Compose a custom decode hook for TrustedIssuersLists backward + // compatibility (plain string array → structured entries) with the + // default gookit/config hooks for env-var and time-duration parsing. + opt.DecoderConfig = &mapstructure.DecoderConfig{ + TagName: "mapstructure", + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + TrustedIssuersListsDecodeHook(), + config.ValDecodeHookFunc(true, false), + ), + } }) config.AddDriver(yaml.Driver) usuario := os.Getenv("DB_USER") diff --git a/config/provider_test.go b/config/provider_test.go index 5d85bd0..aab1688 100644 --- a/config/provider_test.go +++ b/config/provider_test.go @@ -82,7 +82,7 @@ func Test_ReadConfig(t *testing.T) { { Type: "VerifiableCredential", TrustedParticipantsLists: []TrustedParticipantsList{{Type: "ebsi", Url: "https://tir-pdc.ebsi.fiware.dev"}}, - TrustedIssuersLists: []string{"https://til-pdc.ebsi.fiware.dev"}, + TrustedIssuersLists: TrustedIssuersLists{{Type: "ebsi", Url: "https://til-pdc.ebsi.fiware.dev"}}, }, }, PresentationDefinition: &PresentationDefinition{ @@ -247,6 +247,62 @@ func Test_ReadConfig(t *testing.T) { } } +// TestReadConfigV5 verifies that a YAML config with "ebsi-v5" structured +// TrustedIssuersLists and TrustedParticipantsLists is correctly parsed. +func TestReadConfigV5(t *testing.T) { + config.Reset() + gotConfig, err := ReadConfig("data/config_test_v5.yaml") + assert.NoError(t, err, "ReadConfig should not return an error for v5 config") + + services := gotConfig.ConfigRepo.Services + assert.Len(t, services, 1) + assert.Equal(t, "testServiceV5", services[0].Id) + + credentials := services[0].ServiceScopes["v5Scope"].Credentials + assert.Len(t, credentials, 1) + cred := credentials[0] + assert.Equal(t, "VerifiableCredential", cred.Type) + + // Verify structured TrustedIssuersLists with type "ebsi-v5" + assert.Len(t, cred.TrustedIssuersLists, 1) + assert.Equal(t, "ebsi-v5", cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://til-v5.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url) + + // Verify TrustedParticipantsLists with type "ebsi-v5" + assert.Len(t, cred.TrustedParticipantsLists, 1) + assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[0].Type) + assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url) +} + +// TestReadConfigMixed verifies backward-compatible parsing of a YAML config +// that uses legacy string-array TrustedIssuersLists alongside structured +// TrustedParticipantsLists with mixed ebsi and ebsi-v5 types. +func TestReadConfigMixed(t *testing.T) { + config.Reset() + gotConfig, err := ReadConfig("data/config_test_mixed.yaml") + assert.NoError(t, err, "ReadConfig should not return an error for mixed config") + + services := gotConfig.ConfigRepo.Services + assert.Len(t, services, 1) + assert.Equal(t, "testServiceMixed", services[0].Id) + + credentials := services[0].ServiceScopes["mixedScope"].Credentials + assert.Len(t, credentials, 1) + cred := credentials[0] + + // Legacy string array format should default to type "ebsi" + assert.Len(t, cred.TrustedIssuersLists, 1) + assert.Equal(t, DEFAULT_LIST_TYPE, cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://til-pdc.ebsi.fiware.dev", cred.TrustedIssuersLists[0].Url) + + // Mixed participants: ebsi and ebsi-v5 + assert.Len(t, cred.TrustedParticipantsLists, 2) + assert.Equal(t, "ebsi", cred.TrustedParticipantsLists[0].Type) + assert.Equal(t, "https://tir-pdc.ebsi.fiware.dev", cred.TrustedParticipantsLists[0].Url) + assert.Equal(t, "ebsi-v5", cred.TrustedParticipantsLists[1].Type) + assert.Equal(t, "https://tir-v5.ebsi.fiware.dev", cred.TrustedParticipantsLists[1].Url) +} + // TestRefreshTokenConfigDefaults verifies that the refresh token configuration // fields receive correct default values when absent from the YAML input and // are correctly parsed when explicitly set. diff --git a/database/integration_test.go b/database/integration_test.go index db063a6..5571f30 100644 --- a/database/integration_test.go +++ b/database/integration_test.go @@ -85,7 +85,7 @@ func TestIntegration_FullCRUDToCacheFlow(t *testing.T) { Credentials: []config.Credential{ { Type: "VerifiableCredential", - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "sub"}, RequireCompliance: true, JwtInclusion: config.JwtInclusion{ @@ -212,7 +212,7 @@ func TestIntegration_FullCRUDToCacheFlow(t *testing.T) { // GetTrustedIssuersLists issuersLists, err := credConfig.GetTrustedIssuersLists(serviceID, "defaultScope", "VerifiableCredential") require.NoError(t, err) - assert.Equal(t, []string{"https://tir.example.com"}, issuersLists) + assert.Equal(t, []config.TrustedIssuersList{{Type: "ebsi", Url: "https://tir.example.com"}}, issuersLists) // --- Step 4: Update the service → verify changes propagate --- updatedScopes := map[string]config.ScopeEntry{ @@ -220,7 +220,7 @@ func TestIntegration_FullCRUDToCacheFlow(t *testing.T) { Credentials: []config.Credential{ { Type: "UpdatedCredential", - TrustedIssuersLists: []string{"https://tir-updated.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir-updated.example.com"}}, RequireCompliance: false, }, }, @@ -435,7 +435,7 @@ func TestIntegration_CredentialTypeLookupsFullChain(t *testing.T) { Credentials: []config.Credential{ { Type: "CredTypeA", - TrustedIssuersLists: []string{"https://til-a.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://til-a.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "ebsi", Url: "https://tpl-a.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "holderId"}, RequireCompliance: true, @@ -443,7 +443,7 @@ func TestIntegration_CredentialTypeLookupsFullChain(t *testing.T) { }, { Type: "CredTypeB", - TrustedIssuersLists: []string{"https://til-b.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://til-b.example.com"}}, RequireCompliance: false, JwtInclusion: config.JwtInclusion{Enabled: &FALSE_OPTION}, }, @@ -566,7 +566,7 @@ func TestIntegration_CredentialTypeLookupsFullChain(t *testing.T) { // Trusted issuers lists tilA, err := credConfig.GetTrustedIssuersLists(serviceID, "scopeA", "CredTypeA") require.NoError(t, err) - assert.Equal(t, []string{"https://til-a.example.com"}, tilA) + assert.Equal(t, []config.TrustedIssuersList{{Type: "ebsi", Url: "https://til-a.example.com"}}, tilA) // Trusted participants lists tplA, err := credConfig.GetTrustedParticipantLists(serviceID, "scopeA", "CredTypeA") diff --git a/database/migration_compat_test.go b/database/migration_compat_test.go index c1c7052..03eb236 100644 --- a/database/migration_compat_test.go +++ b/database/migration_compat_test.go @@ -100,7 +100,8 @@ func TestMigrationCompat_CCSJavaFormatRoundTrip(t *testing.T) { require.Len(t, cred.TrustedIssuersLists, 1) - assert.Equal(t, "https://tir.dsba.fiware.dev/v3/issuers", cred.TrustedIssuersLists[0]) + assert.Equal(t, "ebsi", cred.TrustedIssuersLists[0].Type) + assert.Equal(t, "https://tir.dsba.fiware.dev/v3/issuers", cred.TrustedIssuersLists[0].Url) assert.True(t, cred.HolderVerification.Enabled) assert.Equal(t, "sub", cred.HolderVerification.Claim) @@ -152,7 +153,7 @@ func TestMigrationCompat_GoWriteCCSRead(t *testing.T) { Credentials: []config.Credential{ { Type: "VerifiableCredential", - TrustedIssuersLists: []string{"https://til.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://til.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "gaia-x", Url: "https://tpl.example.com"}}, HolderVerification: config.HolderVerification{Enabled: false, Claim: ""}, RequireCompliance: true, diff --git a/database/models.go b/database/models.go index e710636..071d6f9 100644 --- a/database/models.go +++ b/database/models.go @@ -269,8 +269,10 @@ type CredentialDB struct { CredentialStatus config.CredentialStatus `json:"credentialStatus,omitempty" mapstructure:"credentialStatus,omitempty"` } +// VO converts a CredentialDB into its config.Credential value object, mapping +// the unified EndpointEntry list back into separate participants and issuers lists. func (cred CredentialDB) VO() config.Credential { - trustedIssuerList := make([]string, 0, len(cred.TrustedIssuersLists)) + trustedIssuerList := make(config.TrustedIssuersLists, 0, len(cred.TrustedIssuersLists)) trustedParticipantsList := make([]config.TrustedParticipantsList, 0, len(cred.TrustedIssuersLists)) for _, trustedIssuer := range cred.TrustedIssuersLists { switch trustedIssuer.Type { @@ -284,7 +286,14 @@ func (cred CredentialDB) VO() config.Credential { Url: trustedIssuer.Endpoint, }) case config.TrustedIssuers: - trustedIssuerList = append(trustedIssuerList, trustedIssuer.Endpoint) + listType := trustedIssuer.ListType + if listType == "" { + listType = config.DEFAULT_LIST_TYPE + } + trustedIssuerList = append(trustedIssuerList, config.TrustedIssuersList{ + Type: listType, + Url: trustedIssuer.Endpoint, + }) } } @@ -299,6 +308,9 @@ func (cred CredentialDB) VO() config.Credential { } } +// FromVO converts a config.Credential value object into a CredentialDB, +// merging the separate participants and issuers lists into a unified +// EndpointEntry slice. func (c CredentialDB) FromVO(cv config.Credential) CredentialDB { trustedLists := make([]config.EndpointEntry, 0, len(cv.TrustedParticipantsLists)+len(cv.TrustedIssuersLists)) for _, tp := range cv.TrustedParticipantsLists { @@ -312,11 +324,15 @@ func (c CredentialDB) FromVO(cv config.Credential) CredentialDB { Endpoint: tp.Url, }) } - for _, endpoint := range cv.TrustedIssuersLists { + for _, issuer := range cv.TrustedIssuersLists { + listType := issuer.Type + if listType == "" { + listType = config.DEFAULT_LIST_TYPE + } trustedLists = append(trustedLists, config.EndpointEntry{ Type: config.TrustedIssuers, - ListType: config.DEFAULT_LIST_TYPE, - Endpoint: endpoint, + ListType: listType, + Endpoint: issuer.Url, }) } return CredentialDB{ diff --git a/database/models_test.go b/database/models_test.go index 7fdbb14..5c6c7ed 100644 --- a/database/models_test.go +++ b/database/models_test.go @@ -192,6 +192,158 @@ func TestCredentialQueryDB_FromVO_ClaimsConverted(t *testing.T) { assert.Equal(t, [][]string{{"c1"}}, db.ClaimSets) } +// --------------------------------------------------------------------------- +// CredentialDB — TrustedIssuersLists round-trip (ebsi-v5 support) +// --------------------------------------------------------------------------- + +// TestCredentialDB_VO_TrustedListsV5 verifies that CredentialDB.VO() correctly +// maps EndpointEntry entries (including "ebsi-v5" typed ones) back to their +// respective TrustedIssuersLists and TrustedParticipantsLists with preserved +// Type and Url fields. +func TestCredentialDB_VO_TrustedListsV5(t *testing.T) { + tests := []struct { + name string + endpoints []config.EndpointEntry + expectedIssuers config.TrustedIssuersLists + expectedParticipants config.TrustedParticipantsLists + }{ + { + name: "single ebsi-v5 issuer", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{}, + }, + { + name: "mixed ebsi and ebsi-v5 issuers", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi", Endpoint: "https://til-ebsi.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{}, + }, + { + name: "ebsi-v5 participant and issuer", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedParticipants, ListType: "ebsi-v5", Endpoint: "https://tir-v5.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + { + name: "empty list type defaults to ebsi", + endpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "", Endpoint: "https://til.example.com"}, + {Type: config.TrustedParticipants, ListType: "", Endpoint: "https://tir.example.com"}, + }, + expectedIssuers: config.TrustedIssuersLists{ + {Type: config.DEFAULT_LIST_TYPE, Url: "https://til.example.com"}, + }, + expectedParticipants: config.TrustedParticipantsLists{ + {Type: config.DEFAULT_LIST_TYPE, Url: "https://tir.example.com"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + db := CredentialDB{ + Type: "VerifiableCredential", + TrustedIssuersLists: tc.endpoints, + } + vo := db.VO() + assert.Equal(t, tc.expectedIssuers, vo.TrustedIssuersLists) + assert.Equal(t, tc.expectedParticipants, vo.TrustedParticipantsLists) + }) + } +} + +// TestCredentialDB_FromVO_TrustedListsV5 verifies that CredentialDB.FromVO() +// correctly merges TrustedIssuersLists and TrustedParticipantsLists (including +// "ebsi-v5" typed entries) into a unified EndpointEntry slice. +func TestCredentialDB_FromVO_TrustedListsV5(t *testing.T) { + tests := []struct { + name string + issuers config.TrustedIssuersLists + participants config.TrustedParticipantsLists + expectedEndpoints []config.EndpointEntry + }{ + { + name: "single ebsi-v5 issuer from VO", + issuers: config.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + participants: nil, + expectedEndpoints: []config.EndpointEntry{ + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + }, + { + name: "mixed ebsi and ebsi-v5 from VO", + issuers: config.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + participants: config.TrustedParticipantsLists{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + expectedEndpoints: []config.EndpointEntry{ + {Type: config.TrustedParticipants, ListType: "ebsi-v5", Endpoint: "https://tir-v5.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi", Endpoint: "https://til-ebsi.example.com"}, + {Type: config.TrustedIssuers, ListType: "ebsi-v5", Endpoint: "https://til-v5.example.com"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + vo := config.Credential{ + Type: "VerifiableCredential", + TrustedIssuersLists: tc.issuers, + TrustedParticipantsLists: tc.participants, + } + db := CredentialDB{}.FromVO(vo) + assert.Equal(t, tc.expectedEndpoints, db.TrustedIssuersLists) + }) + } +} + +// TestCredentialDB_RoundTrip_V5 verifies that a CredentialDB with "ebsi-v5" +// typed entries survives a full FromVO() → VO() round-trip with type and URL +// preserved. +func TestCredentialDB_RoundTrip_V5(t *testing.T) { + original := config.Credential{ + Type: "VerifiableCredential", + TrustedIssuersLists: config.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: config.TrustedParticipantsLists{ + {Type: "ebsi", Url: "https://tir-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + } + + db := CredentialDB{}.FromVO(original) + roundTripped := db.VO() + + assert.Equal(t, original.Type, roundTripped.Type) + assert.Equal(t, original.TrustedIssuersLists, roundTripped.TrustedIssuersLists) + assert.Equal(t, original.TrustedParticipantsLists, roundTripped.TrustedParticipantsLists) +} + // --------------------------------------------------------------------------- // DCQLDB — full VO / FromVO conversion // --------------------------------------------------------------------------- diff --git a/database/repository_test.go b/database/repository_test.go index d1cb7b2..16ffad1 100644 --- a/database/repository_test.go +++ b/database/repository_test.go @@ -29,7 +29,7 @@ func sampleService(id string) config.ConfiguredService { Credentials: []config.Credential{ { Type: "VerifiableCredential", - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "sub"}, }, }, diff --git a/db/migration/changelog.xml b/db/migration/changelog.xml new file mode 100644 index 0000000..8bc5b28 --- /dev/null +++ b/db/migration/changelog.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/db/migration/v3/changelog-v3_0_0.xml b/db/migration/v3/changelog-v3_0_0.xml new file mode 100644 index 0000000..39bd4eb --- /dev/null +++ b/db/migration/v3/changelog-v3_0_0.xml @@ -0,0 +1,41 @@ + + + + + + + Backfill empty listType in scope_entry credentials JSON with default value "ebsi" + + UPDATE scope_entry + SET credentials = REPLACE(credentials, '"listType":""', '"listType":"ebsi"') + WHERE credentials LIKE '%"listType":""%' + + + + + + + + diff --git a/tir/tirClient.go b/tir/tirClient.go index 98ac77a..6834763 100644 --- a/tir/tirClient.go +++ b/tir/tirClient.go @@ -3,6 +3,7 @@ package tir import ( "encoding/json" "errors" + "fmt" "net/http" "time" @@ -16,15 +17,27 @@ import ( const ISSUERS_V4_PATH = "v4/issuers" const ISSUERS_V3_PATH = "v3/issuers" +const ISSUERS_V5_PATH = "v5/issuers" const DID_V4_Path = "v4/identifiers" +// maxPaginationPages limits the number of pages fetched during v5 attribute +// list pagination to prevent infinite loops or excessively long request chains. +const maxPaginationPages = 100 + var ErrorTirNoResponse = errors.New("no_response_from_tir") var ErrorTirEmptyResponse = errors.New("empty_response_from_tir") type TirClient interface { IsTrustedParticipant(tirEndpoints string, did string) (trusted bool) GetTrustedIssuer(tirEndpoints []string, did string) (exists bool, trustedIssuer TrustedIssuer, err error) + // IsTrustedParticipantV5 checks whether the given DID is registered as a + // trusted participant at the specified v5 TIR endpoint. + IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) + // GetTrustedIssuerV5 fetches a trusted issuer from v5 TIR endpoints. The v5 + // API requires multi-step fetching: get issuer, list attributes (with + // pagination), then fetch each attribute individually. + GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer TrustedIssuer, err error) } /** @@ -75,6 +88,47 @@ type Claim struct { AllowedValues []interface{} `json:"allowedValues"` } +// TrustedIssuerV5Response represents the response from GET /v5/issuers/{did}. +// The Attributes field contains a URL reference to the paginated attributes list, +// rather than inline attribute data as in v3/v4. +type TrustedIssuerV5Response struct { + Did string `json:"did"` + Attributes string `json:"attributes"` + HasAttributes bool `json:"hasAttributes"` +} + +// AttributesListV5Response represents a paginated list of attribute references +// returned by GET /v5/issuers/{did}/attributes. +type AttributesListV5Response struct { + Items []AttributeListItem `json:"items"` + Links PaginationLinks `json:"links"` + Total int `json:"total"` + PageSize int `json:"pageSize"` + Self string `json:"self"` +} + +// AttributeListItem represents a single entry in the paginated attributes list, +// containing the attribute's ID and an href to fetch its full details. +type AttributeListItem struct { + ID string `json:"id"` + Href string `json:"href"` +} + +// PaginationLinks holds the pagination navigation links returned by the v5 API. +type PaginationLinks struct { + First string `json:"first"` + Last string `json:"last"` + Next string `json:"next"` + Prev string `json:"prev"` +} + +// AttributeV5Response represents the response from fetching a single attribute +// via GET /v5/issuers/{did}/attributes/{id}. +type AttributeV5Response struct { + Attribute IssuerAttribute `json:"attribute"` + Did string `json:"did"` +} + func NewTirHttpClient(tokenProvider TokenProvider, m2mConfig config.M2M, verifierConfig config.Verifier) (client TirClient, err error) { // disable keep alive, to avoid EOFs due to race conditions @@ -219,5 +273,193 @@ func getIssuerV4Url(did string) string { func getIssuerV3Url(did string) string { return ISSUERS_V3_PATH + "/" + did +} + +// getIssuerV5Url returns the v5 API path for fetching issuer information by DID. +func getIssuerV5Url(did string) string { + return ISSUERS_V5_PATH + "/" + did +} + +// getAttributesV5Url returns the v5 API path for listing an issuer's attributes. +func getAttributesV5Url(did string) string { + return ISSUERS_V5_PATH + "/" + did + "/attributes" +} + +// IsTrustedParticipantV5 checks whether the given DID is registered as a +// trusted participant at the specified v5 TIR endpoint. Returns true if the +// endpoint responds with HTTP 200 for the issuer lookup. +func (tc TirHttpClient) IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) { + if tc.issuerExistsV5(tirEndpoint, did) { + logging.Log().Debugf("Issuer %s is a trusted participant via v5 endpoint %s.", did, tirEndpoint) + return true + } + return false +} + +// issuerExistsV5 checks the v5 endpoint for the existence of a given DID, +// using the cache to avoid redundant requests. +func (tc TirHttpClient) issuerExistsV5(tirEndpoint string, did string) (trusted bool) { + cacheKey := tirEndpoint + "/v5/" + did + exists, hit := tc.tirCache.Get(cacheKey) + + if !hit { + resp, err := tc.client.Get(tirEndpoint, getIssuerV5Url(did)) + if err != nil { + logging.Log().Warnf("V5: Was not able to check issuer %s at %s. Err: %v", did, tirEndpoint, err) + return false + } + if resp == nil { + logging.Log().Warnf("V5: No response for issuer %s from %s.", did, tirEndpoint) + return false + } + logging.Log().Debugf("V5: Issuer %s response from %s is %v", did, tirEndpoint, resp.StatusCode) + exists = resp.StatusCode == 200 + tc.tirCache.Set(cacheKey, exists, cache.DefaultExpiration) + } + + return exists.(bool) +} + +// GetTrustedIssuerV5 fetches a trusted issuer from v5 TIR endpoints. For each +// endpoint it performs a multi-step fetch: (1) get issuer, (2) if the issuer has +// attributes, paginate through the attributes list, (3) fetch each individual +// attribute. The assembled TrustedIssuer is cached for subsequent lookups. +func (tc TirHttpClient) GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer TrustedIssuer, err error) { + for _, tirEndpoint := range tirEndpoints { + cacheKey := tirEndpoint + "/v5/" + did + "/full" + cached, hit := tc.tilCache.Get(cacheKey) + if hit { + return true, cached.(TrustedIssuer), nil + } + + issuer, fetchErr := tc.fetchTrustedIssuerV5(tirEndpoint, did) + if fetchErr != nil { + logging.Log().Warnf("V5: Was not able to get the issuer %s from %s. Err: %v", did, tirEndpoint, fetchErr) + err = fetchErr + continue + } + + logging.Log().Debugf("V5: Got issuer %s.", logging.PrettyPrintObject(issuer)) + tc.tilCache.Set(cacheKey, issuer, cache.DefaultExpiration) + return true, issuer, nil + } + return false, trustedIssuer, err +} + +// fetchTrustedIssuerV5 performs the full multi-step v5 fetch for a single +// endpoint: get issuer metadata, paginate attribute list, and fetch each +// attribute's details. +func (tc TirHttpClient) fetchTrustedIssuerV5(tirEndpoint string, did string) (TrustedIssuer, error) { + // Step 1: GET /v5/issuers/{did} + resp, err := tc.client.Get(tirEndpoint, getIssuerV5Url(did)) + if err != nil { + return TrustedIssuer{}, err + } + if resp == nil { + return TrustedIssuer{}, ErrorTirNoResponse + } + if resp.StatusCode != 200 { + logging.Log().Debugf("V5: Issuer %s not found at %s (status %d).", did, tirEndpoint, resp.StatusCode) + return TrustedIssuer{}, fmt.Errorf("issuer %s not found at %s: status %d", did, tirEndpoint, resp.StatusCode) + } + + var issuerResp TrustedIssuerV5Response + if resp.Body == nil { + return TrustedIssuer{}, ErrorTirEmptyResponse + } + if err := json.NewDecoder(resp.Body).Decode(&issuerResp); err != nil { + return TrustedIssuer{}, fmt.Errorf("failed to decode v5 issuer response: %w", err) + } + + result := TrustedIssuer{Did: issuerResp.Did} + + // Step 2: If issuer has no attributes, return early + if !issuerResp.HasAttributes { + logging.Log().Debugf("V5: Issuer %s has no attributes.", did) + return result, nil + } + + // Step 3: Paginate through attributes list + attributeItems, err := tc.fetchAllAttributeItemsV5(tirEndpoint, did) + if err != nil { + return TrustedIssuer{}, fmt.Errorf("failed to fetch attribute list for %s: %w", did, err) + } + + // Step 4: Fetch each individual attribute + var attributes []IssuerAttribute + for _, item := range attributeItems { + attr, err := tc.fetchSingleAttributeV5(tirEndpoint, item.Href) + if err != nil { + logging.Log().Warnf("V5: Failed to fetch attribute %s for issuer %s: %v", item.ID, did, err) + return TrustedIssuer{}, fmt.Errorf("failed to fetch attribute %s for %s: %w", item.ID, did, err) + } + attributes = append(attributes, attr) + } + + result.Attributes = attributes + return result, nil +} + +// fetchAllAttributeItemsV5 paginates through the v5 attributes list endpoint, +// collecting all AttributeListItem entries across pages. It follows the +// Links.Next URL until exhausted or until the safety limit is reached. +func (tc TirHttpClient) fetchAllAttributeItemsV5(tirEndpoint string, did string) ([]AttributeListItem, error) { + var allItems []AttributeListItem + currentPath := getAttributesV5Url(did) + + for page := 0; page < maxPaginationPages; page++ { + resp, err := tc.client.Get(tirEndpoint, currentPath) + if err != nil { + return nil, fmt.Errorf("failed to fetch attributes page: %w", err) + } + if resp == nil { + return nil, ErrorTirNoResponse + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status %d when fetching attributes", resp.StatusCode) + } + if resp.Body == nil { + return nil, ErrorTirEmptyResponse + } + + var listResp AttributesListV5Response + if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + return nil, fmt.Errorf("failed to decode attributes list: %w", err) + } + + allItems = append(allItems, listResp.Items...) + + // Stop if there is no next page + if listResp.Links.Next == "" || listResp.Links.Next == listResp.Self { + break + } + currentPath = listResp.Links.Next + } + + return allItems, nil +} + +// fetchSingleAttributeV5 fetches a single attribute by its href path from the +// v5 API and returns the parsed IssuerAttribute. +func (tc TirHttpClient) fetchSingleAttributeV5(tirEndpoint string, href string) (IssuerAttribute, error) { + resp, err := tc.client.Get(tirEndpoint, href) + if err != nil { + return IssuerAttribute{}, fmt.Errorf("failed to fetch attribute: %w", err) + } + if resp == nil { + return IssuerAttribute{}, ErrorTirNoResponse + } + if resp.StatusCode != 200 { + return IssuerAttribute{}, fmt.Errorf("unexpected status %d when fetching attribute", resp.StatusCode) + } + if resp.Body == nil { + return IssuerAttribute{}, ErrorTirEmptyResponse + } + + var attrResp AttributeV5Response + if err := json.NewDecoder(resp.Body).Decode(&attrResp); err != nil { + return IssuerAttribute{}, fmt.Errorf("failed to decode attribute response: %w", err) + } + return attrResp.Attribute, nil } diff --git a/tir/tirClient_test.go b/tir/tirClient_test.go index e191107..e69df99 100644 --- a/tir/tirClient_test.go +++ b/tir/tirClient_test.go @@ -125,6 +125,393 @@ func TestGetTrustedIssuer(t *testing.T) { } +func TestIsTrustedParticipantV5(t *testing.T) { + type test struct { + testName string + testIssuer string + testEndpoint string + mockResponses map[string]*http.Response + mockErrors map[string]error + expectedResult bool + } + tests := []test{ + { + testName: "V5: The issuer should be found.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + }, + expectedResult: true, + }, + { + testName: "V5: The issuer should not be found when 404.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getNotFoundResponse(), + }, + expectedResult: false, + }, + { + testName: "V5: The issuer should not be found on network error.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockErrors: map[string]error{ + "https://my-tir.org/v5/issuers/did:web:test.org": errors.New("network_error"), + }, + expectedResult: false, + }, + { + testName: "V5: The issuer should not be found when response is nil.", + testIssuer: "did:web:test.org", + testEndpoint: "https://my-tir.org", + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": nil, + }, + expectedResult: false, + }, + } + + for _, tc := range tests { + common.ResetGlobalCache() + t.Run(tc.testName, func(t *testing.T) { + tirClient := TirHttpClient{ + client: mockClient{responses: tc.mockResponses, errors: tc.mockErrors}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + isTrusted := tirClient.IsTrustedParticipantV5(tc.testEndpoint, tc.testIssuer) + assert.Equal(t, tc.expectedResult, isTrusted) + }) + } +} + +func TestGetTrustedIssuerV5(t *testing.T) { + type test struct { + testName string + testIssuer string + testEndpoints []string + mockResponses map[string]*http.Response + mockErrors map[string]error + expectedIssuer string + expectedAttrCount int + expectExists bool + expectError bool + } + tests := []test{ + { + testName: "V5: Single attribute fetched successfully.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "", // no next page + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1", IssuerType: "type1", Tao: "tao1", RootTao: "root1"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 1, + expectExists: true, + }, + { + testName: "V5: Multiple attributes fetched successfully.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{ + {ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}, + {ID: "attr2", Href: "v5/issuers/did:web:test.org/attributes/attr2"}, + }, + "", // no next page + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1", IssuerType: "type1"}, + "did:web:test.org", + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr2": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash2", Body: "body2", IssuerType: "type2"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 2, + expectExists: true, + }, + { + testName: "V5: Pagination across two pages.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "v5/issuers/did:web:test.org/attributes?page=2", // next page + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes?page=2": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr2", Href: "v5/issuers/did:web:test.org/attributes/attr2"}}, + "", // no more pages + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1"}, + "did:web:test.org", + ), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr2": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash2", Body: "body2"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 2, + expectExists: true, + }, + { + testName: "V5: Issuer with hasAttributes false returns empty attributes.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", false), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 0, + expectExists: true, + }, + { + testName: "V5: Issuer not found (404).", + testIssuer: "did:web:unknown.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:unknown.org": getNotFoundResponse(), + }, + expectExists: false, + expectError: true, + }, + { + testName: "V5: Attribute fetch error causes failure.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://my-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "", + ), + }, + mockErrors: map[string]error{ + "https://my-tir.org/v5/issuers/did:web:test.org/attributes/attr1": errors.New("fetch_failed"), + }, + expectExists: false, + expectError: true, + }, + { + testName: "V5: Second endpoint succeeds after first fails.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://bad-tir.org", "https://good-tir.org"}, + mockResponses: map[string]*http.Response{ + "https://bad-tir.org/v5/issuers/did:web:test.org": getNotFoundResponse(), + "https://good-tir.org/v5/issuers/did:web:test.org": getV5IssuerResponse("did:web:test.org", true), + "https://good-tir.org/v5/issuers/did:web:test.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:test.org/attributes/attr1"}}, + "", + ), + "https://good-tir.org/v5/issuers/did:web:test.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1"}, + "did:web:test.org", + ), + }, + expectedIssuer: "did:web:test.org", + expectedAttrCount: 1, + expectExists: true, + }, + { + testName: "V5: Network error at issuer endpoint.", + testIssuer: "did:web:test.org", + testEndpoints: []string{"https://my-tir.org"}, + mockErrors: map[string]error{ + "https://my-tir.org/v5/issuers/did:web:test.org": errors.New("connection_refused"), + }, + expectExists: false, + expectError: true, + }, + } + + for _, tc := range tests { + common.ResetGlobalCache() + t.Run(tc.testName, func(t *testing.T) { + tirClient := TirHttpClient{ + client: mockClient{responses: tc.mockResponses, errors: tc.mockErrors}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + exists, issuer, err := tirClient.GetTrustedIssuerV5(tc.testEndpoints, tc.testIssuer) + + assert.Equal(t, tc.expectExists, exists, "exists mismatch") + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + if tc.expectExists { + assert.Equal(t, tc.expectedIssuer, issuer.Did, "issuer DID mismatch") + assert.Equal(t, tc.expectedAttrCount, len(issuer.Attributes), "attribute count mismatch") + } + }) + } +} + +func TestGetTrustedIssuerV5_Caching(t *testing.T) { + common.ResetGlobalCache() + + // Use a real go-cache so we can verify caching behavior + realTilCache := newTestCache() + + did := "did:web:cached.org" + endpoint := "https://my-tir.org" + + responses := map[string]*http.Response{ + "https://my-tir.org/v5/issuers/did:web:cached.org": getV5IssuerResponse(did, true), + "https://my-tir.org/v5/issuers/did:web:cached.org/attributes": getV5AttributesListResponse( + []AttributeListItem{{ID: "attr1", Href: "v5/issuers/did:web:cached.org/attributes/attr1"}}, + "", + ), + "https://my-tir.org/v5/issuers/did:web:cached.org/attributes/attr1": getV5SingleAttributeResponse( + IssuerAttribute{Hash: "hash1", Body: "body1"}, + did, + ), + } + + countingClient := &requestCountingClient{ + inner: mockClient{responses: responses}, + } + + tirClient := TirHttpClient{ + client: countingClient, + tilCache: realTilCache, + tirCache: mockCache{}, + } + + // First call should make HTTP requests + exists1, issuer1, err1 := tirClient.GetTrustedIssuerV5([]string{endpoint}, did) + assert.True(t, exists1) + assert.NoError(t, err1) + assert.Equal(t, did, issuer1.Did) + assert.Equal(t, 1, len(issuer1.Attributes)) + firstCallCount := countingClient.count + + // Second call should hit cache (no additional HTTP requests) + exists2, issuer2, err2 := tirClient.GetTrustedIssuerV5([]string{endpoint}, did) + assert.True(t, exists2) + assert.NoError(t, err2) + assert.Equal(t, did, issuer2.Did) + assert.Equal(t, 1, len(issuer2.Attributes)) + + // Verify no additional HTTP requests were made + assert.Equal(t, firstCallCount, countingClient.count, "second call should use cache, not make HTTP requests") +} + +// requestCountingClient wraps an HttpGetClient and counts requests made. +type requestCountingClient struct { + inner HttpGetClient + count int +} + +func (rcc *requestCountingClient) Get(tirAddress string, tirPath string) (resp *http.Response, err error) { + rcc.count++ + return rcc.inner.Get(tirAddress, tirPath) +} + +// testCache is a simple cache implementation backed by a map for testing +// caching behavior (unlike mockCache which always returns cache misses). +type testCache struct { + data map[string]interface{} +} + +func newTestCache() *testCache { + return &testCache{data: make(map[string]interface{})} +} + +func (tc *testCache) Add(k string, x interface{}, d time.Duration) error { + tc.data[k] = x + return nil +} + +func (tc *testCache) Set(k string, x interface{}, d time.Duration) { + tc.data[k] = x +} + +func (tc *testCache) Get(k string) (interface{}, bool) { + v, ok := tc.data[k] + return v, ok +} + +func (tc *testCache) Delete(k string) { + delete(tc.data, k) +} + +func (tc *testCache) GetWithExpiration(k string) (interface{}, time.Time, bool) { + v, ok := tc.data[k] + return v, time.Now(), ok +} + +// --- V5 test helpers --- + +func getV5IssuerResponse(did string, hasAttributes bool) *http.Response { + body := fmt.Sprintf( + `{"did": "%s", "attributes": "v5/issuers/%s/attributes", "hasAttributes": %t}`, + did, did, hasAttributes, + ) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func getV5AttributesListResponse(items []AttributeListItem, nextLink string) *http.Response { + itemsJSON := "[" + for i, item := range items { + if i > 0 { + itemsJSON += "," + } + itemsJSON += fmt.Sprintf(`{"id": "%s", "href": "%s"}`, item.ID, item.Href) + } + itemsJSON += "]" + + linksJSON := `{"first": "", "last": "", "prev": ""` + if nextLink != "" { + linksJSON += fmt.Sprintf(`, "next": "%s"`, nextLink) + } else { + linksJSON += `, "next": ""` + } + linksJSON += "}" + + body := fmt.Sprintf( + `{"items": %s, "links": %s, "total": %d, "pageSize": %d, "self": ""}`, + itemsJSON, linksJSON, len(items), len(items), + ) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func getV5SingleAttributeResponse(attr IssuerAttribute, did string) *http.Response { + body := fmt.Sprintf( + `{"attribute": {"hash": "%s", "body": "%s", "issuerType": "%s", "tao": "%s", "rootTao": "%s"}, "did": "%s"}`, + attr.Hash, attr.Body, attr.IssuerType, attr.Tao, attr.RootTao, did, + ) + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + } +} + func getNotFoundResponse() *http.Response { return &http.Response{StatusCode: 404} } @@ -173,3 +560,226 @@ func getTestServer(path string, errorCode int) *httptest.Server { } })) } + +// TestV5AttributeRetrieval_Integration verifies correct retrieval of attributes +// from a v5 TIR API using a real HTTP test server. The test exercises the +// complete multi-step v5 flow: (1) GET issuer, (2) paginate attribute list, +// (3) fetch each individual attribute — and validates that the returned +// TrustedIssuer contains the expected attribute data. +func TestV5AttributeRetrieval_Integration(t *testing.T) { + type test struct { + testName string + did string + handlers map[string]http.HandlerFunc + expectedExists bool + expectedError bool + expectedDid string + expectedAttrCount int + // expectedAttrs maps attribute index to the expected IssuerAttribute fields. + expectedAttrs map[int]IssuerAttribute + } + + tests := []test{ + { + testName: "Single attribute retrieved from v5 API via httptest server.", + did: "did:web:example.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:example.org": jsonHandler(200, `{ + "did": "did:web:example.org", + "attributes": "v5/issuers/did:web:example.org/attributes", + "hasAttributes": true + }`), + "/v5/issuers/did:web:example.org/attributes": jsonHandler(200, `{ + "items": [{"id": "a1", "href": "v5/issuers/did:web:example.org/attributes/a1"}], + "links": {"first": "", "last": "", "next": "", "prev": ""}, + "total": 1, + "pageSize": 1, + "self": "" + }`), + "/v5/issuers/did:web:example.org/attributes/a1": jsonHandler(200, `{ + "attribute": {"hash": "sha256-abc", "body": "dGVzdC1ib2R5", "issuerType": "credential-issuer", "tao": "did:web:tao.org", "rootTao": "did:web:root.org"}, + "did": "did:web:example.org" + }`), + }, + expectedExists: true, + expectedDid: "did:web:example.org", + expectedAttrCount: 1, + expectedAttrs: map[int]IssuerAttribute{ + 0: {Hash: "sha256-abc", Body: "dGVzdC1ib2R5", IssuerType: "credential-issuer", Tao: "did:web:tao.org", RootTao: "did:web:root.org"}, + }, + }, + { + testName: "Multiple attributes across two pages retrieved via httptest server.", + did: "did:web:multi.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:multi.org": jsonHandler(200, `{ + "did": "did:web:multi.org", + "attributes": "v5/issuers/did:web:multi.org/attributes", + "hasAttributes": true + }`), + "/v5/issuers/did:web:multi.org/attributes": jsonHandler(200, `{ + "items": [{"id": "attr-1", "href": "v5/issuers/did:web:multi.org/attributes/attr-1"}], + "links": {"first": "", "last": "", "next": "v5/issuers/did:web:multi.org/attributes?page=2", "prev": ""}, + "total": 2, + "pageSize": 1, + "self": "" + }`), + "/v5/issuers/did:web:multi.org/attributes?page=2": jsonHandler(200, `{ + "items": [{"id": "attr-2", "href": "v5/issuers/did:web:multi.org/attributes/attr-2"}], + "links": {"first": "", "last": "", "next": "", "prev": ""}, + "total": 2, + "pageSize": 1, + "self": "" + }`), + "/v5/issuers/did:web:multi.org/attributes/attr-1": jsonHandler(200, `{ + "attribute": {"hash": "h1", "body": "Ym9keS0x", "issuerType": "type-A", "tao": "did:web:tao-a.org", "rootTao": "did:web:root-a.org"}, + "did": "did:web:multi.org" + }`), + "/v5/issuers/did:web:multi.org/attributes/attr-2": jsonHandler(200, `{ + "attribute": {"hash": "h2", "body": "Ym9keS0y", "issuerType": "type-B", "tao": "did:web:tao-b.org", "rootTao": "did:web:root-b.org"}, + "did": "did:web:multi.org" + }`), + }, + expectedExists: true, + expectedDid: "did:web:multi.org", + expectedAttrCount: 2, + expectedAttrs: map[int]IssuerAttribute{ + 0: {Hash: "h1", Body: "Ym9keS0x", IssuerType: "type-A", Tao: "did:web:tao-a.org", RootTao: "did:web:root-a.org"}, + 1: {Hash: "h2", Body: "Ym9keS0y", IssuerType: "type-B", Tao: "did:web:tao-b.org", RootTao: "did:web:root-b.org"}, + }, + }, + { + testName: "Issuer with no attributes returns empty attribute list.", + did: "did:web:noattr.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:noattr.org": jsonHandler(200, `{ + "did": "did:web:noattr.org", + "attributes": "", + "hasAttributes": false + }`), + }, + expectedExists: true, + expectedDid: "did:web:noattr.org", + expectedAttrCount: 0, + expectedAttrs: map[int]IssuerAttribute{}, + }, + { + testName: "Issuer not found returns 404.", + did: "did:web:unknown.org", + handlers: map[string]http.HandlerFunc{ + "/v5/issuers/did:web:unknown.org": jsonHandler(404, `{"error": "not found"}`), + }, + expectedExists: false, + expectedError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + common.ResetGlobalCache() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler, ok := tc.handlers[r.URL.RequestURI()] + if !ok { + w.WriteHeader(404) + return + } + handler(w, r) + })) + defer server.Close() + + tirClient := TirHttpClient{ + client: getClient{client: server.Client()}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + + exists, issuer, err := tirClient.GetTrustedIssuerV5([]string{server.URL}, tc.did) + + assert.Equal(t, tc.expectedExists, exists, "exists mismatch") + if tc.expectedError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedDid, issuer.Did, "issuer DID mismatch") + assert.Equal(t, tc.expectedAttrCount, len(issuer.Attributes), "attribute count mismatch") + + for idx, expectedAttr := range tc.expectedAttrs { + actual := issuer.Attributes[idx] + assert.Equal(t, expectedAttr.Hash, actual.Hash, "attribute[%d] Hash mismatch", idx) + assert.Equal(t, expectedAttr.Body, actual.Body, "attribute[%d] Body mismatch", idx) + assert.Equal(t, expectedAttr.IssuerType, actual.IssuerType, "attribute[%d] IssuerType mismatch", idx) + assert.Equal(t, expectedAttr.Tao, actual.Tao, "attribute[%d] Tao mismatch", idx) + assert.Equal(t, expectedAttr.RootTao, actual.RootTao, "attribute[%d] RootTao mismatch", idx) + } + }) + } +} + +// TestV5ParticipantCheck_Integration verifies that IsTrustedParticipantV5 +// correctly identifies registered and unregistered participants when querying +// a real HTTP test server serving v5 API responses. +func TestV5ParticipantCheck_Integration(t *testing.T) { + type test struct { + testName string + did string + serverStatus int + expectedResult bool + } + + tests := []test{ + { + testName: "Registered participant returns true.", + did: "did:web:trusted.org", + serverStatus: 200, + expectedResult: true, + }, + { + testName: "Unregistered participant (404) returns false.", + did: "did:web:unknown.org", + serverStatus: 404, + expectedResult: false, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + common.ResetGlobalCache() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/v5/issuers/" + tc.did + if r.URL.Path == expectedPath { + w.WriteHeader(tc.serverStatus) + if tc.serverStatus == 200 { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"did": "%s", "hasAttributes": false}`, tc.did) + } + } else { + w.WriteHeader(404) + } + })) + defer server.Close() + + tirClient := TirHttpClient{ + client: getClient{client: server.Client()}, + tilCache: mockCache{}, + tirCache: mockCache{}, + } + + result := tirClient.IsTrustedParticipantV5(server.URL, tc.did) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +// jsonHandler returns an http.HandlerFunc that writes the given status code and +// JSON body to the response. This simplifies test server setup for v5 API +// endpoint mocking. +func jsonHandler(statusCode int, body string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(body)) + } +} diff --git a/verifier/credentialsConfig.go b/verifier/credentialsConfig.go index 0d2c0b4..6c9eda7 100644 --- a/verifier/credentialsConfig.go +++ b/verifier/credentialsConfig.go @@ -49,7 +49,7 @@ type CredentialsConfig interface { // GetTrustedIssuersLists returns (EBSI TrustedIssuersRegistry compliant) endpoints for the // given service/credential combination, to check that credentials are issued by trusted issuers // and that the issuer has permission to issue such claims. - GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []string, err error) + GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []config.TrustedIssuersList, err error) // RequiredCredentialTypes returns the credential types that are required for the given service and scope. RequiredCredentialTypes(serviceIdentifier string, scope string) (credentialTypes []string, err error) // GetHolderVerification returns holder verification configuration. @@ -177,7 +177,9 @@ func updateCacheFromServices(services []config.ConfiguredService) { if err != nil { logging.Log().Errorf("failed caching issuers lists in fillCache(): %v", err) } else { - tirEndpoints = append(tirEndpoints, serviceIssuersLists...) + for _, entry := range serviceIssuersLists { + tirEndpoints = append(tirEndpoints, entry.Url) + } } } } @@ -284,7 +286,7 @@ func (cc cacheBasedCredentialsConfig) GetTrustedParticipantLists(serviceIdentifi } // GetTrustedIssuersLists returns trusted issuers list endpoints for the given service, scope, and credential type. -func (cc cacheBasedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []string, err error) { +func (cc cacheBasedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []config.TrustedIssuersList, err error) { logging.Log().Debugf("Get issuers list for %s - %s - %s.", serviceIdentifier, scope, credentialType) cacheEntry, hit := common.GlobalCache.ServiceCache.Get(serviceIdentifier) if hit { @@ -295,7 +297,7 @@ func (cc cacheBasedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier s } } logging.Log().Debugf("No trusted issuers for %s - %s", serviceIdentifier, credentialType) - return []string{}, nil + return []config.TrustedIssuersList{}, nil } // GetComplianceRequired returns whether compliance is required for the given credential type. diff --git a/verifier/db_credentials_config.go b/verifier/db_credentials_config.go index 5d9defa..a7bec8a 100644 --- a/verifier/db_credentials_config.go +++ b/verifier/db_credentials_config.go @@ -145,14 +145,14 @@ func (dbc DbBackedCredentialsConfig) GetTrustedParticipantLists(serviceIdentifie // GetTrustedIssuersLists returns trusted issuers list endpoints for the given // service, scope, and credential type. -func (dbc DbBackedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) ([]string, error) { +func (dbc DbBackedCredentialsConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) ([]config.TrustedIssuersList, error) { svc, err := dbc.getService(serviceIdentifier) if err != nil { - return []string{}, nil + return []config.TrustedIssuersList{}, nil } credential, ok := svc.GetCredential(scope, credentialType) if !ok { - return []string{}, nil + return []config.TrustedIssuersList{}, nil } return credential.TrustedIssuersLists, nil } diff --git a/verifier/db_credentials_config_test.go b/verifier/db_credentials_config_test.go index 716e706..e5102a5 100644 --- a/verifier/db_credentials_config_test.go +++ b/verifier/db_credentials_config_test.go @@ -108,7 +108,7 @@ func testService(id, scopeName, credentialType string) config.ConfiguredService Credentials: []config.Credential{ { Type: credentialType, - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "ebsi", Url: "https://tpl.example.com"}}, HolderVerification: config.HolderVerification{Enabled: true, Claim: "sub"}, RequireCompliance: true, @@ -131,7 +131,7 @@ func testServiceVO(id, scopeName, credentialType string) config.ConfiguredServic Credentials: []config.Credential{ { Type: credentialType, - TrustedIssuersLists: []string{"https://tir.example.com"}, + TrustedIssuersLists: config.TrustedIssuersLists{{Type: "ebsi", Url: "https://tir.example.com"}}, TrustedParticipantsLists: []config.TrustedParticipantsList{{Type: "ebsi", Url: "https://tpl.example.com"}}, }, }, @@ -221,7 +221,7 @@ func TestDbBackedCredentialsConfig_AllInterfaceMethods(t *testing.T) { t.Run("GetTrustedIssuersLists", func(t *testing.T) { til, err := cc.GetTrustedIssuersLists("test-svc", "myScope", "TestCredential") require.NoError(t, err) - assert.Equal(t, []string{"https://tir.example.com"}, til) + assert.Equal(t, []config.TrustedIssuersList{{Type: "ebsi", Url: "https://tir.example.com"}}, til) }) t.Run("GetHolderVerification", func(t *testing.T) { diff --git a/verifier/trustedissuer.go b/verifier/trustedissuer.go index c22f89b..5175e4a 100644 --- a/verifier/trustedissuer.go +++ b/verifier/trustedissuer.go @@ -7,6 +7,7 @@ import ( "github.com/PaesslerAG/jsonpath" "github.com/fiware/VCVerifier/common" + configModel "github.com/fiware/VCVerifier/config" "github.com/fiware/VCVerifier/logging" tir "github.com/fiware/VCVerifier/tir" "github.com/google/go-cmp/cmp" @@ -63,18 +64,38 @@ func (tpvs *TrustedIssuerValidationService) ValidateVC(verifiableCredential *com return false, err } - tilAddress, credentialSupported := til[credentialType] + tilEntries, credentialSupported := til[credentialType] if !credentialSupported { logging.Log().Debugf("No trusted issuers list configured for type %s", credentialType) return false, ErrorNoTilForType } - exist, trustedIssuer, err := tpvs.tirClient.GetTrustedIssuer(tilAddress, verifiableCredential.Contents().Issuer.ID) + // Dispatch by type: collect URLs per registry type and try each. + // "ebsi" (or empty/default) uses v3/v4 auto-detection; "ebsi-v5" uses the v5 API. + ebsiURLs := extractTilURLsByType(tilEntries, typeEbsi) + ebsiV5URLs := extractTilURLsByType(tilEntries, typeEbsiV5) - if err != nil { - logging.Log().Warnf("Was not able to validate trusted issuer. Err: %v", err) - return false, err + var exist bool + var trustedIssuer tir.TrustedIssuer + + // Try ebsi (v3/v4) endpoints first. + if len(ebsiURLs) > 0 { + exist, trustedIssuer, err = tpvs.tirClient.GetTrustedIssuer(ebsiURLs, verifiableCredential.Contents().Issuer.ID) + if err != nil { + logging.Log().Warnf("Was not able to validate trusted issuer via ebsi. Err: %v", err) + return false, err + } + } + + // If not found via ebsi, try ebsi-v5 endpoints. + if !exist && len(ebsiV5URLs) > 0 { + exist, trustedIssuer, err = tpvs.tirClient.GetTrustedIssuerV5(ebsiV5URLs, verifiableCredential.Contents().Issuer.ID) + if err != nil { + logging.Log().Warnf("Was not able to validate trusted issuer via ebsi-v5. Err: %v", err) + return false, err + } } + if !exist { logging.Log().Warnf("Trusted issuer for %s does not exist in context %s.", logging.PrettyPrintObject(verifiableCredential), logging.PrettyPrintObject(validationContext)) return false, ErrorNoTilDefined @@ -93,16 +114,40 @@ func (tpvs *TrustedIssuerValidationService) ValidateVC(verifiableCredential *com return true, err } -func isWildcardTil(tilList []string) (isWildcard bool, err error) { - if len(tilList) == 1 && tilList[0] == WILDCARD_TIL { +// isWildcardTil checks whether the given TIL list contains the wildcard +// entry ("*"). A wildcard must be the only entry; mixing it with other +// entries is considered invalid configuration. +func isWildcardTil(tilList []configModel.TrustedIssuersList) (isWildcard bool, err error) { + if len(tilList) == 1 && tilList[0].Url == WILDCARD_TIL { return true, err } - if len(tilList) > 1 && slices.Contains(tilList, WILDCARD_TIL) { //nolint:govet + urls := make([]string, len(tilList)) + for i, entry := range tilList { + urls[i] = entry.Url + } + if len(tilList) > 1 && slices.Contains(urls, WILDCARD_TIL) { //nolint:govet return false, ErrorInvalidTil } return false, err } +// extractTilURLsByType collects URL strings from TrustedIssuersList entries +// matching the given registry type. Entries with an empty type are treated +// as "ebsi" (the default) for backward compatibility. +func extractTilURLsByType(entries []configModel.TrustedIssuersList, listType string) []string { + urls := make([]string, 0, len(entries)) + for _, entry := range entries { + entryType := entry.Type + if entryType == "" { + entryType = typeEbsi + } + if entryType == listType { + urls = append(urls, entry.Url) + } + } + return urls +} + func verifyWithCredentialsConfig(verifiableCredential *common.Credential, credentials []tir.Credential) (result bool, err error) { credentialsConfigMap := map[string][]tir.Credential{} diff --git a/verifier/trustedissuer_test.go b/verifier/trustedissuer_test.go index 1b3ddc5..998911f 100644 --- a/verifier/trustedissuer_test.go +++ b/verifier/trustedissuer_test.go @@ -74,8 +74,11 @@ func TestVerifyVC_Issuers(t *testing.T) { credentialToVerifiy common.Credential verificationContext ValidationContext participantsList []string + participantsV5List []string tirResponse tir.TrustedIssuer + tirResponseV5 tir.TrustedIssuer tirError error + tirErrorV5 error expectedResult bool } @@ -136,6 +139,51 @@ func TestVerifyVC_Issuers(t *testing.T) { {testName: "If a wildcard til and another til is configured for the type, the vc should be rejected.", credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), verificationContext: getInvalidMixedVerificationContext(), participantsList: []string{"did:test:issuer"}, tirError: nil, expectedResult: false}, + // ebsi-v5 test cases + {testName: "If the issuer is found via ebsi-v5, the vc should be accepted.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getV5VerificationContext(), + participantsV5List: []string{"did:test:issuer"}, + tirResponseV5: getTrustedIssuer([]tir.IssuerAttribute{getAttribute(tir.TimeRange{}, "VerifiableCredential", map[string][]interface{}{})}), + expectedResult: true}, + {testName: "If the issuer is not found via ebsi-v5, the vc should be rejected.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getV5VerificationContext(), + participantsV5List: []string{}, + tirResponseV5: tir.TrustedIssuer{}, + expectedResult: false}, + {testName: "If the ebsi-v5 registry returns an error, the vc should be rejected.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getV5VerificationContext(), + participantsV5List: []string{"did:test:issuer"}, + tirResponseV5: tir.TrustedIssuer{}, + tirErrorV5: errors.New("v5-error"), + expectedResult: false}, + // Mixed ebsi + ebsi-v5 test cases + {testName: "Mixed types: issuer found via ebsi (v3/v4) takes precedence.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getMixedEbsiVerificationContext(), + participantsList: []string{"did:test:issuer"}, + tirResponse: getTrustedIssuer([]tir.IssuerAttribute{getAttribute(tir.TimeRange{}, "VerifiableCredential", map[string][]interface{}{})}), + participantsV5List: []string{}, + tirResponseV5: tir.TrustedIssuer{}, + expectedResult: true}, + {testName: "Mixed types: issuer not in ebsi but found via ebsi-v5.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getMixedEbsiVerificationContext(), + participantsList: []string{}, + tirResponse: tir.TrustedIssuer{}, + participantsV5List: []string{"did:test:issuer"}, + tirResponseV5: getTrustedIssuer([]tir.IssuerAttribute{getAttribute(tir.TimeRange{}, "VerifiableCredential", map[string][]interface{}{})}), + expectedResult: true}, + {testName: "Mixed types: issuer not found in either registry should be rejected.", + credentialToVerifiy: getVerifiableCredential("testClaim", "testValue"), + verificationContext: getMixedEbsiVerificationContext(), + participantsList: []string{}, + tirResponse: tir.TrustedIssuer{}, + participantsV5List: []string{}, + tirResponseV5: tir.TrustedIssuer{}, + expectedResult: false}, } for _, tc := range tests { @@ -143,7 +191,15 @@ func TestVerifyVC_Issuers(t *testing.T) { logging.Log().Info("TestVerifyVC +++++++++++++++++ Running test: ", tc.testName) - trustedIssuerVerificationService := TrustedIssuerValidationService{mockTirClient{tc.participantsList, tc.tirResponse, tc.tirError}} + tirClient := mockTirClient{ + participantsList: tc.participantsList, + participantsV5List: tc.participantsV5List, + expectedIssuer: tc.tirResponse, + expectedIssuerV5: tc.tirResponseV5, + expectedError: tc.tirError, + expectedErrorV5: tc.tirErrorV5, + } + trustedIssuerVerificationService := TrustedIssuerValidationService{tirClient} result, _ := trustedIssuerVerificationService.ValidateVC(&tc.credentialToVerifiy, tc.verificationContext) if result != tc.expectedResult { t.Errorf("%s - Expected result %v but was %v.", tc.testName, tc.expectedResult, result) @@ -249,19 +305,77 @@ func getTrustedIssuer(attributes []tir.IssuerAttribute) tir.TrustedIssuer { } func getVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"http://my-til.org"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-til.org"}}, + }, + } } func getWildcardVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"*"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "*"}}, + }, + } } func getInvalidMixedVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"*", "http://my-til.org"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "*"}, {Type: "ebsi", Url: "http://my-til.org"}}, + }, + } } func getWildcardAndNormalVerificationContext() ValidationContext { - return TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, "SecondType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}, trustedIssuersLists: map[string][]string{"VerifiableCredential": {"*"}, "SecondType": {"http://my-til.org"}}} + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + "SecondType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi", Url: "*"}}, + "SecondType": {{Type: "ebsi", Url: "http://my-til.org"}}, + }, + } +} + +// getV5VerificationContext returns a context with only ebsi-v5 typed TIL entries. +func getV5VerificationContext() ValidationContext { + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": {{Type: "ebsi-v5", Url: "http://my-v5-til.org"}}, + }, + } +} + +// getMixedEbsiVerificationContext returns a context with both ebsi and ebsi-v5 +// typed TIL entries for the same credential type. +func getMixedEbsiVerificationContext() ValidationContext { + return TrustRegistriesValidationContext{ + trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{ + "VerifiableCredential": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}, + }, + trustedIssuersLists: map[string][]config.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi", Url: "http://my-til.org"}, + {Type: "ebsi-v5", Url: "http://my-v5-til.org"}, + }, + }, + } } func getMultiTypeCredential(types []string, claimName string, value interface{}) common.Credential { diff --git a/verifier/trustedparticipant.go b/verifier/trustedparticipant.go index 5497299..7c49451 100644 --- a/verifier/trustedparticipant.go +++ b/verifier/trustedparticipant.go @@ -13,8 +13,9 @@ var ErrorCannotConverContext = errors.New("cannot_convert_context") var ErrorInvalidCredential = errors.New("invalid_trusted_participant_type") const ( - typeGaiaX = "gaia-x" - typeEbsi = "ebsi" + typeGaiaX = "gaia-x" + typeEbsi = "ebsi" + typeEbsiV5 = "ebsi-v5" ) /** @@ -55,6 +56,10 @@ func (tpvs *TrustedParticipantValidationService) ValidateVC(verifiableCredential logging.Log().Debug("Check at ebsi.") result = tpvs.tirClient.IsTrustedParticipant(participantList.Url, verifiableCredential.Contents().Issuer.ID) } + if participantList.Type == typeEbsiV5 { + logging.Log().Debug("Check at ebsi-v5.") + result = tpvs.tirClient.IsTrustedParticipantV5(participantList.Url, verifiableCredential.Contents().Issuer.ID) + } if participantList.Type == typeGaiaX { logging.Log().Debug("Check at gaia-x.") result = tpvs.gaiaXClient.IsTrustedParticipant(participantList.Url, verifiableCredential.Contents().Issuer.ID) diff --git a/verifier/trustedparticipant_test.go b/verifier/trustedparticipant_test.go index 1b61891..fb6a88e 100644 --- a/verifier/trustedparticipant_test.go +++ b/verifier/trustedparticipant_test.go @@ -19,39 +19,96 @@ func (mgc mockGaiaXClient) IsTrustedParticipant(registryEndpoint string, did str } type mockTirClient struct { - participantsList []string - expectedIssuer tir.TrustedIssuer - expectedError error + participantsList []string + participantsV5List []string + expectedIssuer tir.TrustedIssuer + expectedIssuerV5 tir.TrustedIssuer + expectedError error + expectedErrorV5 error } +// IsTrustedParticipant checks the v3/v4 participants list. func (mtc mockTirClient) IsTrustedParticipant(tirEndpoint string, did string) (trusted bool) { return slices.Contains(mtc.participantsList, did) } +// GetTrustedIssuer returns a trusted issuer from the v3/v4 list. func (mtc mockTirClient) GetTrustedIssuer(tirEndpoints []string, did string) (exists bool, trustedIssuer tir.TrustedIssuer, err error) { return slices.Contains(mtc.participantsList, did), mtc.expectedIssuer, mtc.expectedError } +// IsTrustedParticipantV5 checks the v5 participants list. +func (mtc mockTirClient) IsTrustedParticipantV5(tirEndpoint string, did string) (trusted bool) { + return slices.Contains(mtc.participantsV5List, did) +} + +// GetTrustedIssuerV5 returns a trusted issuer from the v5 list. +func (mtc mockTirClient) GetTrustedIssuerV5(tirEndpoints []string, did string) (exists bool, trustedIssuer tir.TrustedIssuer, err error) { + return slices.Contains(mtc.participantsV5List, did), mtc.expectedIssuerV5, mtc.expectedErrorV5 +} + func TestVerifyVC_Participant(t *testing.T) { type test struct { - testName string - credentialToVerifiy common.Credential - verificationContext ValidationContext - ebsiParticipantsList []string - gaiaXParticipantsList []string - expectedResult bool + testName string + credentialToVerifiy common.Credential + verificationContext ValidationContext + ebsiParticipantsList []string + ebsiV5ParticipantsList []string + gaiaXParticipantsList []string + expectedResult bool } tests := []test{ - {testName: "A credential issued by an ebsi registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a gaia-x registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "gaia-x", Url: "http://gaia-x-registry.org"}}}}, gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a registerd issuer should be successfully validated.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, - {testName: "A credential issued by a not-registerd issuer should be rejected.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": []config.TrustedParticipantsList{{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, ebsiParticipantsList: []string{}, expectedResult: false}, - {testName: "If no registry is configured, the credential should be accepted.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{}}, expectedResult: true}, - {testName: "If no registry is configured, the credential should be accepted.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": []config.TrustedParticipantsList{}}}, expectedResult: true}, - {testName: "If an invalid context is received, the credential should be rejected.", credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), verificationContext: "No-Context", ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: false}, + {testName: "A credential issued by an ebsi registered issuer should be successfully validated.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a gaia-x registered issuer should be successfully validated.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "gaia-x", Url: "http://gaia-x-registry.org"}}}}, + gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a registered issuer in mixed gaia-x/ebsi list should be validated via gaia-x.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + gaiaXParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a registered issuer in mixed gaia-x/ebsi list should be validated via ebsi.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "gaia-x", Url: "http://gaia-x-registry.org"}, {Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a not-registered issuer should be rejected.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}}}}, + ebsiParticipantsList: []string{}, expectedResult: false}, + {testName: "If no registry is configured, the credential should be accepted.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{}}, expectedResult: true}, + {testName: "If an empty registry list is configured, the credential should be accepted.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"VerifiableCredential": {}}}, expectedResult: true}, + {testName: "If an invalid context is received, the credential should be rejected.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: "No-Context", + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: false}, + // ebsi-v5 test cases + {testName: "A credential issued by an ebsi-v5 registered participant should be successfully validated.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiV5ParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "A credential issued by a participant not in ebsi-v5 registry should be rejected.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiV5ParticipantsList: []string{}, expectedResult: false}, + {testName: "Mixed ebsi and ebsi-v5 list: found via ebsi-v5 when not in ebsi.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}, {Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiParticipantsList: []string{}, + ebsiV5ParticipantsList: []string{"did:web:trusted-issuer.org"}, expectedResult: true}, + {testName: "Mixed ebsi and ebsi-v5 list: found via ebsi, v5 not checked.", + credentialToVerifiy: getCredential("did:web:trusted-issuer.org"), + verificationContext: TrustRegistriesValidationContext{trustedParticipantsRegistries: map[string][]config.TrustedParticipantsList{"someType": {{Type: "ebsi", Url: "http://my-trust-registry.org"}, {Type: "ebsi-v5", Url: "http://my-v5-registry.org"}}}}, + ebsiParticipantsList: []string{"did:web:trusted-issuer.org"}, + ebsiV5ParticipantsList: []string{}, expectedResult: true}, } for _, tc := range tests { @@ -59,7 +116,13 @@ func TestVerifyVC_Participant(t *testing.T) { logging.Log().Info("TestVerifyVC +++++++++++++++++ Running test: ", tc.testName) - trustedParticipantVerificationService := TrustedParticipantValidationService{mockTirClient{tc.ebsiParticipantsList, tir.TrustedIssuer{}, nil}, mockGaiaXClient{tc.gaiaXParticipantsList}} + tirClient := mockTirClient{ + participantsList: tc.ebsiParticipantsList, + participantsV5List: tc.ebsiV5ParticipantsList, + expectedIssuer: tir.TrustedIssuer{}, + expectedIssuerV5: tir.TrustedIssuer{}, + } + trustedParticipantVerificationService := TrustedParticipantValidationService{tirClient, mockGaiaXClient{tc.gaiaXParticipantsList}} result, _ := trustedParticipantVerificationService.ValidateVC(&tc.credentialToVerifiy, tc.verificationContext) if result != tc.expectedResult { t.Errorf("%s - Expected result %v but was %v.", tc.testName, tc.expectedResult, result) diff --git a/verifier/verifier.go b/verifier/verifier.go index 5695c39..e861c64 100644 --- a/verifier/verifier.go +++ b/verifier/verifier.go @@ -191,11 +191,12 @@ var localFileAccessor common.FileAccessor = common.DiskFileAccessor{} type ValidationContext interface{} type TrustRegistriesValidationContext struct { - trustedIssuersLists map[string][]string + trustedIssuersLists map[string][]configModel.TrustedIssuersList trustedParticipantsRegistries map[string][]configModel.TrustedParticipantsList } -func (trvc TrustRegistriesValidationContext) GetTrustedIssuersLists() map[string][]string { +// GetTrustedIssuersLists returns the per-credential-type trusted issuers list configuration. +func (trvc TrustRegistriesValidationContext) GetTrustedIssuersLists() map[string][]configModel.TrustedIssuersList { return trvc.trustedIssuersLists } @@ -1122,7 +1123,7 @@ func (v *CredentialVerifier) getHolderValidationContext(clientId string, scope s func (v *CredentialVerifier) getTrustRegistriesValidationContext(clientId string, credentialTypes []string, scope string) (verificationContext TrustRegistriesValidationContext, err error) { logging.Log().Debugf("Create trust registry validation context for client '%s', scope '%s' and credential types %s", clientId, scope, credentialTypes) - trustedIssuersLists := map[string][]string{} + trustedIssuersLists := map[string][]configModel.TrustedIssuersList{} trustedParticipantsRegistries := map[string][]configModel.TrustedParticipantsList{} for _, credentialType := range credentialTypes { @@ -1186,7 +1187,7 @@ func selectValidationContext(service ValidationService, trustContext TrustRegist } func (v *CredentialVerifier) getTrustRegistriesValidationContextFromScope(clientId string, scope string, credentialTypes []string) (verificationContext TrustRegistriesValidationContext, err error) { - trustedIssuersLists := map[string][]string{} + trustedIssuersLists := map[string][]configModel.TrustedIssuersList{} trustedParticipantsRegistries := map[string][]configModel.TrustedParticipantsList{} requiredCredentialTypes, err := v.credentialsConfig.RequiredCredentialTypes(clientId, scope) diff --git a/verifier/verifier_test.go b/verifier/verifier_test.go index 4bfb40c..b481f72 100644 --- a/verifier/verifier_test.go +++ b/verifier/verifier_test.go @@ -107,7 +107,7 @@ func createMockCredentials(serviceId, scope, credentialType, url, holderClaim st credential := configModel.Credential{ Type: credentialType, TrustedParticipantsLists: []configModel.TrustedParticipantsList{{Type: "ebsi", Url: url}}, - TrustedIssuersLists: []string{url}, + TrustedIssuersLists: configModel.TrustedIssuersLists{{Type: "ebsi", Url: url}}, HolderVerification: configModel.HolderVerification{Enabled: holderVerfication, Claim: holderClaim}, } @@ -153,7 +153,7 @@ func (mcc mockCredentialConfig) GetTrustedParticipantLists(serviceIdentifier str } return trustedIssuersRegistryUrl, err } -func (mcc mockCredentialConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []string, err error) { +func (mcc mockCredentialConfig) GetTrustedIssuersLists(serviceIdentifier string, scope string, credentialType string) (trustedIssuersRegistryUrl []configModel.TrustedIssuersList, err error) { if mcc.mockError != nil { return trustedIssuersRegistryUrl, mcc.mockError } @@ -1752,3 +1752,366 @@ func TestInitVerifier_CredentialStatusWiring(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// TIR v5 — getTrustRegistriesValidationContext type propagation +// --------------------------------------------------------------------------- + +// TestGetTrustRegistriesValidationContext_V5TypePropagation verifies that +// getTrustRegistriesValidationContext correctly propagates "ebsi-v5" type +// information from config through to the TrustRegistriesValidationContext. +func TestGetTrustRegistriesValidationContext_V5TypePropagation(t *testing.T) { + logging.Configure(LOGGING_CONFIG) + + type test struct { + testName string + credentialScopes map[string]map[string]configModel.ScopeEntry + clientId string + scope string + credentialTypes []string + expectedIssuersMap map[string][]configModel.TrustedIssuersList + expectedParticipants map[string][]configModel.TrustedParticipantsList + configError error + expectedError error + } + + tests := []test{ + { + testName: "ebsi-v5 issuers and participants are propagated through validation context", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-v5": { + "v5-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-v5", + scope: "v5-scope", + credentialTypes: []string{"VerifiableCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + { + testName: "mixed ebsi and ebsi-v5 types are propagated correctly", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-mixed": { + "mixed-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi", Url: "https://tir-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-mixed", + scope: "mixed-scope", + credentialTypes: []string{"VerifiableCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi", Url: "https://til-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi", Url: "https://tir-ebsi.example.com"}, + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + { + testName: "multiple credential types each with their own v5 config", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-multi": { + "multi-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-vc-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-vc-v5.example.com"}, + }, + }, + { + Type: "CustomerCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi", Url: "https://til-cc-ebsi.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi", Url: "https://tir-cc-ebsi.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-multi", + scope: "multi-scope", + credentialTypes: []string{"VerifiableCredential", "CustomerCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://til-vc-v5.example.com"}, + }, + "CustomerCredential": { + {Type: "ebsi", Url: "https://til-cc-ebsi.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://tir-vc-v5.example.com"}, + }, + "CustomerCredential": { + {Type: "ebsi", Url: "https://tir-cc-ebsi.example.com"}, + }, + }, + }, + { + testName: "config error is propagated", + configError: errors.New("config_failure"), + clientId: "client-err", + scope: "scope-err", + credentialScopes: map[string]map[string]configModel.ScopeEntry{}, + credentialTypes: []string{"VerifiableCredential"}, + expectedError: errors.New("config_failure"), + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + mockConfig := mockCredentialConfig{mockScopes: tc.credentialScopes, mockError: tc.configError} + verifier := CredentialVerifier{credentialsConfig: &mockConfig} + + ctx, err := verifier.getTrustRegistriesValidationContext(tc.clientId, tc.credentialTypes, tc.scope) + + if tc.expectedError != nil { + assert.Error(t, err) + assert.Equal(t, tc.expectedError.Error(), err.Error()) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expectedIssuersMap, ctx.GetTrustedIssuersLists()) + assert.Equal(t, tc.expectedParticipants, ctx.GetTrustedParticipantLists()) + }) + } +} + +// TestGetTrustRegistriesValidationContextFromScope_V5 verifies that +// getTrustRegistriesValidationContextFromScope correctly propagates "ebsi-v5" +// type information and validates required credential types. +func TestGetTrustRegistriesValidationContextFromScope_V5(t *testing.T) { + logging.Configure(LOGGING_CONFIG) + + type test struct { + testName string + credentialScopes map[string]map[string]configModel.ScopeEntry + clientId string + scope string + presentedTypes []string + expectedIssuersMap map[string][]configModel.TrustedIssuersList + expectedParticipants map[string][]configModel.TrustedParticipantsList + expectedError error + } + + tests := []test{ + { + testName: "v5 issuers propagated from scope when all required types are presented", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-v5": { + "v5-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-v5", + scope: "v5-scope", + presentedTypes: []string{"VerifiableCredential"}, + expectedIssuersMap: map[string][]configModel.TrustedIssuersList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + expectedParticipants: map[string][]configModel.TrustedParticipantsList{ + "VerifiableCredential": { + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + }, + }, + { + testName: "missing required credential type returns error", + credentialScopes: map[string]map[string]configModel.ScopeEntry{ + "client-v5": { + "v5-scope": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + }, + }, + }, + }, + }, + clientId: "client-v5", + scope: "v5-scope", + presentedTypes: []string{"OtherCredential"}, + expectedError: ErrorRequiredCredentialNotProvided, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + mockConfig := mockCredentialConfig{mockScopes: tc.credentialScopes} + v := CredentialVerifier{credentialsConfig: &mockConfig} + + ctx, err := v.getTrustRegistriesValidationContextFromScope(tc.clientId, tc.scope, tc.presentedTypes) + + if tc.expectedError != nil { + assert.ErrorIs(t, err, tc.expectedError) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expectedIssuersMap, ctx.GetTrustedIssuersLists()) + assert.Equal(t, tc.expectedParticipants, ctx.GetTrustedParticipantLists()) + }) + } +} + +// TestAuthenticationResponse_V5ValidationServices exercises the full +// AuthenticationResponse flow with "ebsi-v5" configured trust registries +// and a mocked validation service, verifying that the v5-typed trust config +// flows end-to-end through verification to JWT caching. +func TestAuthenticationResponse_V5ValidationServices(t *testing.T) { + logging.Configure(LOGGING_CONFIG) + + trueOption := true + + // Configure mock credentials with ebsi-v5 trust registries + v5CredentialScopes := map[string]map[string]configModel.ScopeEntry{ + "clientId": { + "": { + Credentials: []configModel.Credential{ + { + Type: "VerifiableCredential", + TrustedIssuersLists: configModel.TrustedIssuersLists{ + {Type: "ebsi-v5", Url: "https://til-v5.example.com"}, + }, + TrustedParticipantsLists: []configModel.TrustedParticipantsList{ + {Type: "ebsi-v5", Url: "https://tir-v5.example.com"}, + }, + JwtInclusion: configModel.JwtInclusion{Enabled: &trueOption}, + }, + }, + }, + }, + } + + tests := []authTest{ + { + testName: "Same-device flow with ebsi-v5 trust registries succeeds when credential is valid.", + sameDevice: true, testState: "login-state", + testVP: getVP([]string{"vc"}), + testHolder: "holder", + testSession: loginSession{version: SAME_DEVICE, callback: "https://myhost.org/callback", sessionId: "my-session", clientId: "clientId", requestObject: "requestObjectJwt"}, + requestedState: "login-state", + verificationResult: []bool{true}, + expectedResponse: Response{FlowVersion: SAME_DEVICE, RedirectTarget: "https://myhost.org/callback", Code: "authCode", SessionId: "my-session"}, + }, + { + testName: "Same-device flow with ebsi-v5 trust registries fails when credential is invalid.", + sameDevice: true, testState: "login-state", + testVP: getVP([]string{"vc"}), + testHolder: "holder", + testSession: loginSession{version: SAME_DEVICE, callback: "https://myhost.org/callback", sessionId: "my-session", clientId: "clientId", requestObject: "requestObjectJwt"}, + requestedState: "login-state", + verificationResult: []bool{false}, + expectedError: ErrorInvalidVC, + expectedResponse: Response{}, + }, + } + + for _, tc := range tests { + t.Run(tc.testName, func(t *testing.T) { + sessionCache := mockSessionCache{sessions: map[string]loginSession{}} + if tc.testSession != (loginSession{}) { + sessionCache.sessions[tc.testState] = tc.testSession + } + tokenCache := mockTokenCache{tokens: map[string]tokenStore{}, errorToThrow: tc.tokenCacheError} + httpClient = mockHttpClient{tc.callbackError, nil} + ecdsaKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + testKey, _ := jwk.Import(ecdsaKey) + _ = jwk.AssignKeyID(testKey) + nonceGenerator := mockNonceGenerator{staticValues: []string{"authCode"}} + credentialsConfig := mockCredentialConfig{mockScopes: v5CredentialScopes} + + validationServices := []ValidationService{ + &mockExternalSsiKit{ + verificationResults: tc.verificationResult, + verificationError: tc.verificationError, + }, + } + v := CredentialVerifier{ + did: "did:key:verifier", + signingKey: testKey, + tokenCache: &tokenCache, + sessionCache: &sessionCache, + nonceGenerator: &nonceGenerator, + validationServices: validationServices, + clock: mockClock{}, + credentialsConfig: credentialsConfig, + clientIdentification: configModel.ClientIdentification{Id: "did:key:verifier"}, + } + + sameDeviceResponse, err := v.AuthenticationResponse(tc.requestedState, &tc.testVP) + if tc.expectedError != nil { + assert.Equal(t, tc.expectedError, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedResponse, sameDeviceResponse) + _, found := tokenCache.tokens[sameDeviceResponse.Code] + assert.True(t, found, "a token should be cached after successful authentication") + }) + } +}