diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/biome.xml b/.idea/biome.xml new file mode 100644 index 0000000..8fa01ec --- /dev/null +++ b/.idea/biome.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/.idea/eudi-wallet-functionality.iml b/.idea/eudi-wallet-functionality.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/eudi-wallet-functionality.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..cc3da93 --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..10936c6 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..94d4960 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/package.json b/package.json index afdaaf6..6a85e63 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "publishConfig": { "access": "public", + "main": "./dist/index.mjs", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", "exports": { @@ -52,6 +53,8 @@ "typescript": "~5.9.3" }, "dependencies": { + "@animo-id/eudi-wallet-ts12-validation": "workspace:*", + "@animo-id/eudi-wallet-ts12-credential-metadata-query": "workspace:*", "zod": "^4.3.5" } } diff --git a/packages/credential-metadata-provider/README.md b/packages/credential-metadata-provider/README.md new file mode 100644 index 0000000..e79d859 --- /dev/null +++ b/packages/credential-metadata-provider/README.md @@ -0,0 +1,126 @@ +# @animo-id/eudi-wallet-ts12-credential-metadata-provider + +Issuer-side: serve signed per-credential metadata JWTs per TS12 Section 5. Constructs, locale-filters, and caches signed JWTs on demand. + +No framework dependency — bring your own JWT signer (jose, credo-ts, node:crypto, etc.). + +## Install + +```bash +pnpm add @animo-id/eudi-wallet-ts12-credential-metadata-provider +``` + +## Handler + +The `CredentialMetadataProvider` handles requests to the `credential_metadata_uri` endpoint. It loads unsigned metadata from your store, filters it by the requested locale, signs it via the signer you provide, caches it, and returns the appropriate response. + +Allowed locales are derived automatically from the metadata per TS12 Section 3.5.4 — only locales that fully resolve across all display arrays are served. A warning is emitted for locales that appear in the metadata but cannot fully resolve. + +```ts +import { CredentialMetadataProvider } from '@animo-id/eudi-wallet-ts12-credential-metadata-provider' +import type { CredentialMetadataStore, JwtSigner } from '@animo-id/eudi-wallet-ts12-credential-metadata-provider' + +// Bring your own signer — any library that produces compact JWS. +// The signer owns alg, x5c, and key material. It MUST set +// typ: 'credential-metadata+jwt' and include x5c per Section 5. +import { SignJWT, importPKCS8 } from 'jose' + +const signer: JwtSigner = async (payload) => { + const key = await importPKCS8(signingKeyPem, 'ES256') + return new SignJWT(payload) + .setProtectedHeader({ typ: 'credential-metadata+jwt', alg: 'ES256', x5c }) + .sign(key) +} + +// Implement the store interface — adapt to your database/storage +const store: CredentialMetadataStore = { + // Lightweight identity — called on every request + async getCredentialInfo(credentialId) { + const row = await db.findCredential(credentialId) + if (!row) return undefined + return { + credentialType: row.vct, + format: row.format, + credentialMetadataUri: `https://issuer.example.com/credential-metadata/${credentialId}`, + updatedAt: row.updatedAt, // epoch ms — busts the derived locale cache on change + } + }, + + // Full metadata with all locales — only called on cache miss + async getCredentialMetadata(credentialId) { + return db.getMetadata(credentialId) + // Returns the OID4VCI Section 12.2.4 credential_metadata object: + // { + // display: [ + // { name: 'SuperBank Payment', locale: 'en', logo: {...}, ... }, + // { name: 'SuperBank Zahlung', locale: 'de', logo: {...}, ... }, + // ], + // claims: [ + // { path: ['payment_network'], display: [{ locale: 'en', name: 'Payment network' }, ...] }, + // ], + // transaction_data_types: { + // 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + // claims: [...], + // ui_labels: { affirmative_action_label: [...] }, + // }, + // }, + // } + }, + + // Cached signed JWTs, keyed by (credentialId, canonicalLocale) + async getSignedJwt(credentialId, canonicalLocale) { + return cache.get(`${credentialId}:${canonicalLocale}`) + }, + async saveSignedJwt(credentialId, canonicalLocale, jwt) { + cache.set(`${credentialId}:${canonicalLocale}`, jwt) + }, +} + +const provider = new CredentialMetadataProvider({ + store, + signer, + issuerIdentifier: 'https://issuer.example.com', + expiresInSeconds: 2592000, // 30 days + logger: console, // warns about non-resolvable locales +}) +``` + +In your HTTP handler: + +```ts +app.get('/credential-metadata/:id', async (req, res) => { + const response = await provider.handle(req.params.id, { + accept: req.headers.accept, + acceptLanguage: req.headers['accept-language'], + }) + res.setHeader('Content-Type', response.contentType) + res.send(response.body) +}) +// Accept: application/jwt → signed JWT (constructed + cached if missing) +// Accept: application/json → locale-filtered JSON (no signing) +// Accept absent → application/json (Section 5 default) +``` + +## How locale filtering works + +1. The provider derives allowed locales from the metadata: only locales that fully resolve per TS12 Section 3.5.4 (every `display` array across claims and UI labels produces a match). Non-resolvable locales are logged via `logger.warn`. + +2. The `Accept-Language` header is parsed (RFC 9110 §12.5.4, quality-sorted, `q=0` excluded) and intersected with the allowed locales. + +3. The default locale canonicalizer picks the first (highest-quality) locale and reduces it to its primary language subtag (`en-US` → `en`). Override with `canonicalizeLocale` in the config. + +4. The result is used as a cache key. Signed JWTs are cached by `(credentialId, canonicalLocale)` and busted when the store's `updatedAt` changes. + +## JWT structure per Section 5 + +The handler builds the payload and passes it to your signer: + +``` +Payload (built by the provider): + { iss, sub, format, iat, exp, credential_metadata_uri, credential_metadata: {...} } + +Header (built by your signer): + { typ: 'credential-metadata+jwt', alg: 'ES256', x5c: [...] } +``` + +The signer receives the payload, adds the JOSE header with its own `alg`, `x5c`, and key material, and returns a compact JWS string. diff --git a/packages/credential-metadata-provider/package.json b/packages/credential-metadata-provider/package.json new file mode 100644 index 0000000..2b452ab --- /dev/null +++ b/packages/credential-metadata-provider/package.json @@ -0,0 +1,42 @@ +{ + "name": "@animo-id/eudi-wallet-ts12-credential-metadata-provider", + "description": "EUDI Wallet TS12 credential metadata provider — create and serve signed per-credential metadata JWTs per Section 5", + "version": "0.1.0", + "license": "Apache-2.0", + "author": "Frederic Artus Nieto for DSGV", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/animo/eudi-wallet-functionality", + "directory": "packages/credential-metadata-provider" + }, + "scripts": { + "types:check": "tsc --noEmit", + "build": "tsdown src/index.ts --format esm --dts --clean --sourcemap" + }, + "dependencies": { + "@animo-id/eudi-wallet-ts12-validation": "workspace:*", + "@hapi/accept": "^6.0.3", + "bcp-47-match": "^2.0.3" + }, + "devDependencies": { + "tsdown": "^0.18.4", + "typescript": "~5.9.3" + } +} diff --git a/packages/credential-metadata-provider/src/credential-metadata-provider.ts b/packages/credential-metadata-provider/src/credential-metadata-provider.ts new file mode 100644 index 0000000..aca5e24 --- /dev/null +++ b/packages/credential-metadata-provider/src/credential-metadata-provider.ts @@ -0,0 +1,125 @@ +import type { CredentialMetadata } from '@animo-id/eudi-wallet-ts12-validation' +import { + buildLocaleKey, + defaultLocaleCanonicalizer, + deriveAllowedLocales, + filterByAllowList, + filterMetadataByLocales, + negotiateMediaType, + parseAcceptLanguage, +} from './filter-locale' +import type { CredentialMetadataProviderConfig, CredentialMetadataResponse } from './types' + +function jsonResponse(status: number, body: string): CredentialMetadataResponse { + return { status, contentType: 'application/json', body } +} + +function jwtResponse(jwt: string): CredentialMetadataResponse { + return { status: 200, contentType: 'application/jwt', body: jwt } +} + +function errorResponse(status: number, error: string, supportedLocales?: string[]): CredentialMetadataResponse { + return jsonResponse(status, JSON.stringify({ error, supported_locales: supportedLocales })) +} + +/** + * TS12 Section 5 — Handler for the `credential_metadata_uri` endpoint. + */ +export class CredentialMetadataProvider { + private readonly config: CredentialMetadataProviderConfig + private derivedLocaleCache = new Map() + + constructor(config: CredentialMetadataProviderConfig) { + this.config = config + } + + async handle( + credentialId: string, + headers: { accept?: string; acceptLanguage?: string } + ): Promise { + const { store, canonicalizeLocale = defaultLocaleCanonicalizer } = this.config + + // Content type negotiation (TS12 Section 5: absent Accept defaults to JSON) + let acceptsJwt = false + if (headers.accept !== undefined) { + try { + acceptsJwt = negotiateMediaType(headers.accept, ['application/jwt', 'application/json']) === 'application/jwt' + } catch { + return errorResponse(400, 'invalid_accept_header') + } + } + + const info = await store.getCredentialInfo(credentialId) + if (!info) return errorResponse(404, 'not_found') + + const derived = await this.getOrDeriveLocales(credentialId, info.updatedAt) + if (!derived) return errorResponse(404, 'not_found') + + // Language negotiation + let inputLocales: string[] + if (headers.acceptLanguage === undefined) { + inputLocales = derived.locales + } else { + let parsed: { locales: string[]; acceptsAll: boolean } + try { + parsed = parseAcceptLanguage(headers.acceptLanguage) + } catch { + return errorResponse(400, 'invalid_accept_language') + } + + if (parsed.acceptsAll) { + inputLocales = derived.locales + } else if (parsed.locales.length === 0) { + return errorResponse(404, 'no_matching_locale', derived.locales) + } else { + inputLocales = filterByAllowList(parsed.locales, derived.locales) + if (inputLocales.length === 0) { + return errorResponse(404, 'no_matching_locale', derived.locales) + } + } + } + + const selectedLocales = canonicalizeLocale(inputLocales) + const localeKey = buildLocaleKey(selectedLocales) + + if (acceptsJwt) { + const cached = await store.getSignedJwt(credentialId, localeKey) + if (cached) return jwtResponse(cached) + } + + const filteredMetadata = filterMetadataByLocales(derived.metadata, selectedLocales) + + if (!acceptsJwt) return jsonResponse(200, JSON.stringify(filteredMetadata)) + + const nowSeconds = Math.floor(Date.now() / 1000) + const payload: Record = { + iss: this.config.issuerIdentifier, + sub: info.credentialType, + format: info.format, + iat: nowSeconds, + exp: nowSeconds + this.config.expiresInSeconds, + credential_metadata_uri: info.credentialMetadataUri, + credential_metadata: filteredMetadata, + } + + const jwt = await this.config.signer(payload) + await store.saveSignedJwt(credentialId, localeKey, jwt) + + return jwtResponse(jwt) + } + + private async getOrDeriveLocales( + credentialId: string, + updatedAt: number + ): Promise<{ locales: string[]; metadata: CredentialMetadata } | undefined> { + const cached = this.derivedLocaleCache.get(credentialId) + if (cached && cached.updatedAt === updatedAt) { + return cached + } + const metadata = await this.config.store.getCredentialMetadata(credentialId) + if (!metadata) return undefined + const locales = deriveAllowedLocales(metadata, this.config.logger) + this.derivedLocaleCache.set(credentialId, { updatedAt, locales, metadata }) + return { locales, metadata } + } +} diff --git a/packages/credential-metadata-provider/src/filter-locale.ts b/packages/credential-metadata-provider/src/filter-locale.ts new file mode 100644 index 0000000..f422e8a --- /dev/null +++ b/packages/credential-metadata-provider/src/filter-locale.ts @@ -0,0 +1,227 @@ +import type { CredentialMetadata } from '@animo-id/eudi-wallet-ts12-validation' +import Accept from '@hapi/accept' +import { lookup } from 'bcp-47-match' +import type { LocaleCanonicalizer } from './types' + +// ============================================================================= +// Accept-Language parsing via @hapi/accept (RFC 9110 Section 12.5.4) +// ============================================================================= + +/** Result of parsing an Accept-Language header. */ +export interface ParsedAcceptLanguage { + /** Language tags in quality-descending order (q=0 excluded). `*` is included when present. */ + locales: string[] + /** True when `*` was present with q > 0 (client accepts any language). */ + acceptsAll: boolean +} + +/** + * Parse an Accept-Language header. + * + * Uses @hapi/accept which: + * - Throws on malformed headers (caller should catch as 400) + * - Excludes q=0 entries + * - Returns `*` when present + * - Sorts by quality descending + */ +export function parseAcceptLanguage(header: string): ParsedAcceptLanguage { + const all = Accept.languages(header) + const acceptsAll = all.includes('*') + const locales = all.filter((l) => l !== '*') + return { locales, acceptsAll } +} + +// ============================================================================= +// Accept header parsing via @hapi/accept (RFC 9110 Section 12.5.1) +// ============================================================================= + +/** Determine the preferred media type from an Accept header via @hapi/accept. Throws on malformed. */ +export function negotiateMediaType(acceptHeader: string, available: string[]): string | undefined { + const result = Accept.mediaType(acceptHeader, available) + return result || undefined +} + +// ============================================================================= +// Locale canonicalization +// ============================================================================= + +/** + * Default locale canonicalizer: takes the first locale and returns its + * primary language subtag. + * + * `['en-us', 'de-de']` → `['en']` + * `['de-de']` → `['de']` + * `[]` → `[]` + */ +export const defaultLocaleCanonicalizer: LocaleCanonicalizer = (requestedLocales) => { + if (requestedLocales.length === 0) return [] + const primary = requestedLocales[0].split('-')[0] + return [primary] +} + +/** + * Build a stable canonical cache key from a locale array. + * Sorts, deduplicates, and joins with ','. + */ +export function buildLocaleKey(locales: string[]): string { + return [...new Set(locales)].sort().join(',') +} + +// ============================================================================= +// RFC 4647 matching (via bcp-47-match) +// ============================================================================= + +function lookupMatches(range: string, availableTags: string[]): boolean { + return lookup(availableTags, [range]) !== undefined +} + +function lookupMatchesSet(range: string, loweredTags: Set): boolean { + return lookupMatches(range, [...loweredTags]) +} + +// ============================================================================= +// Display array analysis (pre-computed for efficient locale checks) +// ============================================================================= + +interface DisplayArrayInfo { + tags: Set + hasDefault: boolean +} + +function analyzeDisplayArray(entries: Array<{ locale?: string }>): DisplayArrayInfo { + const tags = new Set() + let hasDefault = false + for (const e of entries) { + if (e.locale) tags.add(e.locale.toLowerCase()) + else hasDefault = true + } + return { tags, hasDefault } +} + +function localeMatchesInfo(locale: string, info: DisplayArrayInfo): boolean { + return lookupMatchesSet(locale, info.tags) || info.hasDefault +} + +function collectDisplayArrayInfos(metadata: CredentialMetadata): DisplayArrayInfo[] { + const infos: DisplayArrayInfo[] = [] + + if (metadata.display) infos.push(analyzeDisplayArray(metadata.display)) + + if (metadata.claims) { + for (const claim of metadata.claims) { + if ('display' in claim) infos.push(analyzeDisplayArray(claim.display)) + } + } + + if (metadata.transaction_data_types) { + for (const tdType of Object.values(metadata.transaction_data_types)) { + for (const claim of tdType.claims) { + if ('display' in claim) infos.push(analyzeDisplayArray(claim.display)) + } + for (const entries of Object.values(tdType.ui_labels)) { + if (Array.isArray(entries)) infos.push(analyzeDisplayArray(entries)) + } + } + } + + return infos +} + +// ============================================================================= +// TS12 Section 3.5.4 — Locale resolvability +// ============================================================================= + +export function localeFullyResolves(locale: string, metadata: CredentialMetadata): boolean { + return collectDisplayArrayInfos(metadata).every((info) => localeMatchesInfo(locale, info)) +} + +export function collectMetadataLocales(metadata: CredentialMetadata): string[] { + const locales = new Set() + for (const info of collectDisplayArrayInfos(metadata)) { + for (const tag of info.tags) locales.add(tag) + } + return [...locales] +} + +export function deriveAllowedLocales(metadata: CredentialMetadata, logger?: { warn(message: string): void }): string[] { + const infos = collectDisplayArrayInfos(metadata) + + const allLocales = new Set() + for (const info of infos) { + for (const tag of info.tags) allLocales.add(tag) + } + + const resolvable: string[] = [] + for (const locale of allLocales) { + if (infos.every((info) => localeMatchesInfo(locale, info))) { + resolvable.push(locale) + } else { + logger?.warn( + `Locale '${locale}' appears in credential metadata but does not fully resolve per TS12 Section 3.5.4 — it is missing from at least one display array` + ) + } + } + + return resolvable +} + +// ============================================================================= +// Locale filtering against allow list +// ============================================================================= + +export function filterByAllowList(requestedLocales: string[], allowedLocales: string[]): string[] { + return requestedLocales.filter((l) => lookupMatches(l, allowedLocales)) +} + +// ============================================================================= +// Metadata locale filtering +// ============================================================================= + +export function filterMetadataByLocales(metadata: CredentialMetadata, locales: string[]): CredentialMetadata { + const localeSet = new Set(locales.map((l) => l.toLowerCase())) + + function filterLocaleArray(entries: T[]): T[] { + return entries.filter((e) => !e.locale || localeSet.has(e.locale.toLowerCase())) + } + + function filterClaims( + claims: Array<{ display?: Array<{ locale?: string }> } & Record> + ): typeof claims { + return claims.map((claim) => { + if (!claim.display) return claim + return { ...claim, display: filterLocaleArray(claim.display) } + }) + } + + const result = { ...metadata } + + if (result.display) { + result.display = filterLocaleArray(result.display) + } + + if (result.claims) { + result.claims = filterClaims(result.claims) as typeof result.claims + } + + if (result.transaction_data_types) { + const filteredTypes: Record = {} + for (const [typeKey, typeValue] of Object.entries(result.transaction_data_types)) { + const tdType = typeValue as { + claims: Array<{ display?: Array<{ locale?: string }> } & Record> + ui_labels: Record> + } & Record + + const filteredClaims = filterClaims(tdType.claims) + + const filteredUiLabels: Record = {} + for (const [labelKey, labelEntries] of Object.entries(tdType.ui_labels)) { + filteredUiLabels[labelKey] = filterLocaleArray(labelEntries) + } + + filteredTypes[typeKey] = { ...tdType, claims: filteredClaims, ui_labels: filteredUiLabels } + } + result.transaction_data_types = filteredTypes as CredentialMetadata['transaction_data_types'] + } + + return result +} diff --git a/packages/credential-metadata-provider/src/index.ts b/packages/credential-metadata-provider/src/index.ts new file mode 100644 index 0000000..f37024c --- /dev/null +++ b/packages/credential-metadata-provider/src/index.ts @@ -0,0 +1,3 @@ +export * from './credential-metadata-provider' +export * from './filter-locale' +export * from './types' diff --git a/packages/credential-metadata-provider/src/types.ts b/packages/credential-metadata-provider/src/types.ts new file mode 100644 index 0000000..c57d92a --- /dev/null +++ b/packages/credential-metadata-provider/src/types.ts @@ -0,0 +1,81 @@ +import type { CredentialMetadata } from '@animo-id/eudi-wallet-ts12-validation' + +/** Lightweight credential identity — no heavy metadata payload. */ +export interface CredentialInfo { + /** The credential type identifier — vct or doctype (`sub` claim). */ + credentialType: string + /** The credential format identifier, e.g., 'dc+sd-jwt', 'mso_mdoc' (`format` claim). */ + format: string + /** The URL at which this JWT is served (`credential_metadata_uri` claim). */ + credentialMetadataUri: string + /** Epoch milliseconds of last metadata update. Used to bust the derived locale cache. */ + updatedAt: number +} + +/** + * Storage interface for credential metadata. + * + * `getCredentialInfo` is called on every request (lightweight). + * `getCredentialMetadata` is only called when the metadata is actually needed + * (cache miss or JSON response). + */ +export interface CredentialMetadataStore { + /** Get lightweight credential identity. Called on every request. */ + getCredentialInfo(credentialId: string): Promise + + /** Get the full unsigned credential metadata with all locales. Only called on cache miss. */ + getCredentialMetadata(credentialId: string): Promise + + /** Get a cached signed JWT for a credential + canonical locale key. */ + getSignedJwt(credentialId: string, canonicalLocale: string): Promise + + /** Persist a signed JWT for a credential + canonical locale key. */ + saveSignedJwt(credentialId: string, canonicalLocale: string, jwt: string): Promise +} + +/** + * Signs a credential-metadata+jwt given its payload. + * + * The signer owns all cryptographic details: key material, algorithm, + * certificate chain, and JOSE header construction. It MUST set + * `typ: 'credential-metadata+jwt'` and include `x5c` in the protected header + * per TS12 Section 5. + */ +export type JwtSigner = (payload: Record) => Promise + +/** + * Selects which locales to filter the metadata to, given the requested locales + * (already pre-filtered by the allow list). + * + * Returns an array of locale codes to keep. The provider sorts and joins them + * into a canonical cache key. + * + * Default: takes the first locale and returns its primary language subtag. + * e.g. `['en-us', 'de-de']` → `['en']` + */ +export type LocaleCanonicalizer = (requestedLocales: string[]) => string[] + +/** Configuration for the credential metadata provider handler. */ +export interface CredentialMetadataProviderConfig { + store: CredentialMetadataStore + /** Signs the JWT. The signer handles alg, x5c, and key material. */ + signer: JwtSigner + /** The Credential Issuer Identifier (`iss` claim). */ + issuerIdentifier: string + /** JWT validity in seconds. */ + expiresInSeconds: number + /** + * Selects which locales to use for filtering, given the pre-filtered + * requested locales. Default: primary subtag of the first locale. + */ + canonicalizeLocale?: LocaleCanonicalizer + /** Logger for warnings (e.g. non-resolvable locales). */ + logger?: { warn(message: string): void } +} + +/** HTTP response from the handler. */ +export interface CredentialMetadataResponse { + status: number + contentType: 'application/jwt' | 'application/json' + body: string +} diff --git a/packages/credential-metadata-provider/tsconfig.json b/packages/credential-metadata-provider/tsconfig.json new file mode 100644 index 0000000..f8b80bc --- /dev/null +++ b/packages/credential-metadata-provider/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "noEmitOnError": true, + "lib": ["ES2022"], + "types": [], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["dist"] +} diff --git a/packages/credential-metadata-query/README.md b/packages/credential-metadata-query/README.md new file mode 100644 index 0000000..6680042 --- /dev/null +++ b/packages/credential-metadata-query/README.md @@ -0,0 +1,114 @@ +# @animo-id/eudi-wallet-ts12-credential-metadata-query + +Wallet-side: resolve per-credential metadata, preferring signed JWT with fallback to unsigned JSON or inline metadata. + +## Install + +```bash +pnpm add @animo-id/eudi-wallet-ts12-credential-metadata-query +``` + +Peer dependency: `@credo-ts/core` + +## Setup + +Register as a credo-ts module: + +```ts +import { CredentialMetadataQueryModule } from '@animo-id/eudi-wallet-ts12-credential-metadata-query' + +const agent = new Agent({ + modules: { + credentialMetadataQuery: new CredentialMetadataQueryModule(), + }, +}) +``` + +## Resolve credential metadata + +The unified entry point. Tries signed JWT first, falls back to unsigned JSON, or validates inline metadata: + +```ts +const result = await agent.credentialMetadataQuery.resolveCredentialMetadata({ + credentialRecordId: sdJwtVcRecord.id, + issuerIdentifier: 'https://issuer.example.com', + credentialType: 'https://pay.example.com/card', + + // Option A: resolve from URI (tries signed JWT → falls back to unsigned JSON) + credentialMetadataUri: 'https://issuer.example.com/credential-metadata/card', + credentialX5c: [leafCertBase64, rootCertBase64], // required for JWT verification + + // Option B: use inline metadata directly (when no URI available) + // credentialMetadata: { + // transaction_data_types: { + // 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + // claims: [{ path: ['payee_name'], display: [{ name: 'Payee' }] }], + // ui_labels: { affirmative_action_label: [{ value: 'Confirm' }] }, + // }, + // }, + // }, +}) +``` + +The result is a discriminated union — `credentialMetadata` is always present: + +```ts +// result.credentialMetadata is always available: +// { +// transaction_data_types: { +// 'urn:eudi:sca:eu.europa.ec:payment:single:1': { +// claims: [...], +// ui_labels: { affirmative_action_label: [...] } +// } +// } +// } + +switch (result.source) { + case 'signed-jwt': + result.metadataIntegrity // 'sha256-...' — SRI hash for SCA authorization requests + result.compactJwt // the raw JWT string + result.header // { typ: 'credential-metadata+jwt', alg: 'ES256', x5c: [...] } + result.payload // { iss, sub, format, iat, exp, credential_metadata_uri, credential_metadata } + break + case 'unsigned-json': + // fetched as JSON from the URI, no integrity proof + break + case 'inline': + // validated from issuer metadata directly + break +} +``` + +## Lower-level API + +For direct control over the signed JWT lifecycle: + +```ts +// Fetch, verify (6-step Section 4.1.2), and persist +const verified = await agent.credentialMetadataQuery.fetchAndStore({ + credentialMetadataUri: 'https://issuer.example.com/credential-metadata/card', + issuerIdentifier: 'https://issuer.example.com', + credentialType: 'https://pay.example.com/card', + credentialRecordId: sdJwtVcRecord.id, + credentialX5c: [leafCertBase64, rootCertBase64], +}) +// verified.metadataIntegrity — 'sha256-...' +// verified.payload.credential_metadata — the metadata object + +// Re-verify stored JWT before presentation (re-fetches on verification failure) +const reverified = await agent.credentialMetadataQuery.getVerifiedMetadata({ + credentialRecordId: sdJwtVcRecord.id, + credentialX5c: [leafCertBase64, rootCertBase64], +}) + +// Renew if approaching expiry (default threshold: 1 hour) +await agent.credentialMetadataQuery.renewIfNeeded({ + credentialRecordId: sdJwtVcRecord.id, + credentialX5c: [leafCertBase64, rootCertBase64], + thresholdSeconds: 3600, +}) + +// Compute SRI integrity hash for an arbitrary JWT +const integrity = agent.credentialMetadataQuery.computeMetadataIntegrity(compactJwt) +// 'sha256-...' +``` diff --git a/packages/credential-metadata-query/package.json b/packages/credential-metadata-query/package.json new file mode 100644 index 0000000..a55e369 --- /dev/null +++ b/packages/credential-metadata-query/package.json @@ -0,0 +1,44 @@ +{ + "name": "@animo-id/eudi-wallet-ts12-credential-metadata-query", + "description": "EUDI Wallet TS12 credential metadata resolution — credo-ts module for resolving, verifying, storing, and renewing per Sections 4.1 and 5", + "version": "0.1.0", + "license": "Apache-2.0", + "author": "Frederic Artus Nieto for DSGV", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/animo/eudi-wallet-functionality", + "directory": "packages/credential-metadata-query" + }, + "scripts": { + "types:check": "tsc --noEmit", + "build": "tsdown src/index.ts --format esm --dts --clean --sourcemap" + }, + "dependencies": { + "@animo-id/eudi-wallet-ts12-validation": "workspace:*", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@credo-ts/core": "*" + }, + "devDependencies": { + "tsdown": "^0.18.4", + "typescript": "~5.9.3" + } +} diff --git a/packages/credential-metadata-query/src/credential-metadata-query-api.ts b/packages/credential-metadata-query/src/credential-metadata-query-api.ts new file mode 100644 index 0000000..4a8bdc6 --- /dev/null +++ b/packages/credential-metadata-query/src/credential-metadata-query-api.ts @@ -0,0 +1,52 @@ +import type { AgentContext } from '@credo-ts/core' +// biome-ignore lint/style/useImportType: DI requires runtime class reference +import { CredentialMetadataQueryService } from './credential-metadata-query-service' +import type { + FetchAndStoreOptions, + GetVerifiedMetadataOptions, + RenewIfNeededOptions, + ResolveCredentialMetadataOptions, + ResolvedCredentialMetadata, + VerifiedCredentialMetadata, +} from './types' + +/** + * Public API for credential metadata resolution. + * + * Exposed on the Agent as `agent.credentialMetadataQuery.*` when the + * `CredentialMetadataQueryModule` is registered. + */ +export class CredentialMetadataQueryApi { + private service: CredentialMetadataQueryService + private agentContext: AgentContext + + constructor(service: CredentialMetadataQueryService, agentContext: AgentContext) { + this.service = service + this.agentContext = agentContext + } + + /** Resolve credential metadata: tries signed JWT, falls back to unsigned JSON or inline. */ + async resolveCredentialMetadata(options: ResolveCredentialMetadataOptions): Promise { + return this.service.resolveCredentialMetadata(this.agentContext, options) + } + + /** Fetch signed JWT, verify, and store linked to a credential record. */ + async fetchAndStore(options: FetchAndStoreOptions): Promise { + return this.service.fetchAndStore(this.agentContext, options) + } + + /** Load stored JWT, re-verify, and recompute `metadata_integrity`. Re-fetches on failure. */ + async getVerifiedMetadata(options: GetVerifiedMetadataOptions): Promise { + return this.service.getVerifiedMetadata(this.agentContext, options) + } + + /** Check if stored JWT needs renewal and re-fetch if so. */ + async renewIfNeeded(options: RenewIfNeededOptions): Promise { + return this.service.renewIfNeeded(this.agentContext, options) + } + + /** Compute the W3C SRI integrity value for a signed credential metadata JWT. */ + computeMetadataIntegrity(compactJwt: string): string { + return this.service.computeMetadataIntegrity(compactJwt) + } +} diff --git a/packages/credential-metadata-query/src/credential-metadata-query-module.ts b/packages/credential-metadata-query/src/credential-metadata-query-module.ts new file mode 100644 index 0000000..ab475f7 --- /dev/null +++ b/packages/credential-metadata-query/src/credential-metadata-query-module.ts @@ -0,0 +1,43 @@ +import type { DependencyManager, Module } from '@credo-ts/core' +import { CredentialMetadataQueryApi } from './credential-metadata-query-api' +import { CredentialMetadataQueryService } from './credential-metadata-query-service' +import { CredentialMetadataJwtRepository } from './repository/credential-metadata-jwt-repository' + +/** + * Credo-ts module for TS12 credential metadata resolution. + * + * Supports three resolution modes: + * - Signed JWT via `credential_metadata_uri` (preferred for SCA, Section 4.1) + * - Unsigned JSON via `credential_metadata_uri` (fallback) + * - Inline `credential_metadata` from issuer metadata + * + * @example + * ```typescript + * const agent = new Agent({ + * modules: { + * credentialMetadataQuery: new CredentialMetadataQueryModule(), + * } + * }) + * + * const result = await agent.credentialMetadataQuery.resolveCredentialMetadata({ + * credentialMetadataUri: 'https://issuer.example.com/credential-metadata/Card', + * issuerIdentifier: 'https://issuer.example.com', + * credentialType: 'https://pay.example.com/card', + * credentialRecordId: sdJwtVcRecord.id, + * credentialX5c: ['...'], + * }) + * + * if (result.source === 'signed-jwt') { + * // result.metadataIntegrity, result.compactJwt available + * } + * // result.credentialMetadata always available + * ``` + */ +export class CredentialMetadataQueryModule implements Module { + readonly api = CredentialMetadataQueryApi + + register(dependencyManager: DependencyManager): void { + dependencyManager.registerSingleton(CredentialMetadataQueryService) + dependencyManager.registerSingleton(CredentialMetadataJwtRepository) + } +} diff --git a/packages/credential-metadata-query/src/credential-metadata-query-service.ts b/packages/credential-metadata-query/src/credential-metadata-query-service.ts new file mode 100644 index 0000000..d236711 --- /dev/null +++ b/packages/credential-metadata-query/src/credential-metadata-query-service.ts @@ -0,0 +1,330 @@ +import { createHash } from 'node:crypto' +import { + zCredentialMetadata, + zCredentialMetadataJwtHeader, + zCredentialMetadataJwtPayload, +} from '@animo-id/eudi-wallet-ts12-validation' +import { type AgentContext, JwsService, Jwt, X509Certificate } from '@credo-ts/core' +import { CredentialMetadataJwtRecord } from './repository/credential-metadata-jwt-record' +// biome-ignore lint/style/useImportType: DI requires runtime class reference +import { CredentialMetadataJwtRepository } from './repository/credential-metadata-jwt-repository' +import type { + CredentialVerificationContext, + FetchAndStoreOptions, + GetVerifiedMetadataOptions, + RenewIfNeededOptions, + ResolveCredentialMetadataOptions, + ResolvedCredentialMetadata, + VerifiedCredentialMetadata, +} from './types' + +const CREDENTIAL_METADATA_JWT_MEDIA_TYPE = 'application/jwt' + +function nowEpochSeconds(): number { + return Math.floor(Date.now() / 1000) +} + +function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +interface VerifyOptions extends CredentialVerificationContext { + compactJwt: string + issuerIdentifier: string + credentialType: string + expectedCredentialMetadataUri?: string +} + +/** + * TS12 Sections 4.1, 5 — Service for resolving credential metadata. + * + * Supports three resolution modes: + * - Signed JWT via `credential_metadata_uri` (preferred for SCA) + * - Unsigned JSON via `credential_metadata_uri` + * - Inline `credential_metadata` from issuer metadata + */ +export class CredentialMetadataQueryService { + private credentialMetadataJwtRepository: CredentialMetadataJwtRepository + + constructor(credentialMetadataJwtRepository: CredentialMetadataJwtRepository) { + this.credentialMetadataJwtRepository = credentialMetadataJwtRepository + } + + /** + * Unified entry point: resolve credential metadata preferring signed JWT. + * + * 1. If `credentialMetadataUri` present: try signed JWT, fall back to unsigned JSON + * 2. If only inline `credentialMetadata` present: validate and return + * 3. Neither: throw + */ + async resolveCredentialMetadata( + agentContext: AgentContext, + options: ResolveCredentialMetadataOptions + ): Promise { + if (options.credentialMetadataUri) { + if (!options.credentialX5c) { + throw new Error('credentialX5c is required when resolving from credential_metadata_uri') + } + + // Try signed JWT first + try { + const verified = await this.fetchAndStore(agentContext, { + credentialMetadataUri: options.credentialMetadataUri, + issuerIdentifier: options.issuerIdentifier, + credentialType: options.credentialType, + credentialRecordId: options.credentialRecordId, + credentialX5c: options.credentialX5c, + trustedCertificates: options.trustedCertificates, + acceptLanguage: options.acceptLanguage, + }) + return { + source: 'signed-jwt', + credentialMetadata: verified.payload.credential_metadata, + ...verified, + } + } catch { + // Fall back to unsigned JSON + const credentialMetadata = await this.fetchUnsignedCredentialMetadata( + options.credentialMetadataUri, + options.acceptLanguage + ) + return { source: 'unsigned-json', credentialMetadata } + } + } + + if (options.credentialMetadata) { + const result = zCredentialMetadata.safeParse(options.credentialMetadata) + if (!result.success) { + throw new Error(`Inline credential metadata validation failed — ${result.error.message}`) + } + return { source: 'inline', credentialMetadata: result.data } + } + + throw new Error('Either credentialMetadataUri or credentialMetadata must be provided') + } + + /** Fetch signed JWT, verify (Section 4.1.2), persist (Section 4.1.3), return verified metadata. */ + async fetchAndStore(agentContext: AgentContext, options: FetchAndStoreOptions): Promise { + const compactJwt = await this.fetchCredentialMetadataJwt(options.credentialMetadataUri, options.acceptLanguage) + + const verified = await this.verifyCredentialMetadataJwt(agentContext, { + compactJwt, + issuerIdentifier: options.issuerIdentifier, + credentialType: options.credentialType, + credentialX5c: options.credentialX5c, + trustedCertificates: options.trustedCertificates, + expectedCredentialMetadataUri: options.credentialMetadataUri, + }) + + const existing = await this.credentialMetadataJwtRepository.findByCredentialRecordId( + agentContext, + options.credentialRecordId + ) + + if (existing) { + existing.compactJwt = verified.compactJwt + existing.credentialMetadataUri = options.credentialMetadataUri + existing.issuerIdentifier = options.issuerIdentifier + existing.credentialType = options.credentialType + existing.format = verified.payload.format + existing.expiresAtSeconds = verified.payload.exp + await this.credentialMetadataJwtRepository.update(agentContext, existing) + } else { + const record = new CredentialMetadataJwtRecord({ + compactJwt: verified.compactJwt, + credentialMetadataUri: options.credentialMetadataUri, + issuerIdentifier: options.issuerIdentifier, + credentialType: options.credentialType, + format: verified.payload.format, + expiresAtSeconds: verified.payload.exp, + credentialRecordId: options.credentialRecordId, + }) + await this.credentialMetadataJwtRepository.save(agentContext, record) + } + + return verified + } + + /** Load stored JWT, re-verify, recompute `metadata_integrity` (Section 4.1.3). Re-fetches on failure. */ + async getVerifiedMetadata( + agentContext: AgentContext, + options: GetVerifiedMetadataOptions + ): Promise { + const record = await this.credentialMetadataJwtRepository.getByCredentialRecordId( + agentContext, + options.credentialRecordId + ) + + try { + return await this.verifyCredentialMetadataJwt(agentContext, { + compactJwt: record.compactJwt, + issuerIdentifier: record.issuerIdentifier, + credentialType: record.credentialType, + credentialX5c: options.credentialX5c, + trustedCertificates: options.trustedCertificates, + expectedCredentialMetadataUri: record.credentialMetadataUri, + }) + } catch (storedError) { + try { + return await this.refetchFromRecord(agentContext, record, options) + } catch (refetchError) { + throw new Error( + `Stored metadata verification failed and re-fetch also failed. ` + + `Stored: ${errorMessage(storedError)}. Re-fetch: ${errorMessage(refetchError)}` + ) + } + } + } + + /** Check if stored JWT needs renewal and re-fetch if so (Section 4.1.4). */ + async renewIfNeeded(agentContext: AgentContext, options: RenewIfNeededOptions): Promise { + const record = await this.credentialMetadataJwtRepository.getByCredentialRecordId( + agentContext, + options.credentialRecordId + ) + + const thresholdSeconds = options.thresholdSeconds ?? 3600 + if (record.expiresAtSeconds - nowEpochSeconds() <= thresholdSeconds) { + await this.refetchFromRecord(agentContext, record, options) + } + } + + /** Compute the W3C SRI integrity value of a signed credential metadata JWT (Section 3.7.1). */ + computeMetadataIntegrity(compactJwt: string): string { + const hash = createHash('sha256').update(compactJwt, 'utf8').digest('base64') + return `sha256-${hash}` + } + + private async refetchFromRecord( + agentContext: AgentContext, + record: CredentialMetadataJwtRecord, + context: CredentialVerificationContext & { credentialRecordId: string } + ): Promise { + return this.fetchAndStore(agentContext, { + credentialMetadataUri: record.credentialMetadataUri, + issuerIdentifier: record.issuerIdentifier, + credentialType: record.credentialType, + credentialRecordId: context.credentialRecordId, + credentialX5c: context.credentialX5c, + trustedCertificates: context.trustedCertificates, + }) + } + + /** + * TS12 Section 4.1.2 — Full 6-step verification. + */ + private async verifyCredentialMetadataJwt( + agentContext: AgentContext, + options: VerifyOptions + ): Promise { + const { + compactJwt, + issuerIdentifier, + credentialType, + credentialX5c, + trustedCertificates, + expectedCredentialMetadataUri, + } = options + + const jwt = Jwt.fromSerializedJwt(compactJwt) + + // Step 1 + const headerResult = zCredentialMetadataJwtHeader.safeParse(jwt.header) + if (!headerResult.success) { + throw new Error(`Step 1 failed: invalid JOSE header — ${headerResult.error.message}`) + } + const header = headerResult.data + + // Steps 2 + 3 + const jwsService = agentContext.dependencyManager.resolve(JwsService) + const { isValid } = await jwsService.verifyJws(agentContext, { jws: compactJwt, trustedCertificates }) + if (!isValid) { + throw new Error('Steps 2/3 failed: JWT signature or certificate chain verification failed') + } + + const payloadResult = zCredentialMetadataJwtPayload.safeParse(jwt.payload.toJson()) + if (!payloadResult.success) { + throw new Error(`Payload validation failed — ${payloadResult.error.message}`) + } + const payload = payloadResult.data + + // Step 4 + if (payload.iss !== issuerIdentifier) { + throw new Error(`Step 4 failed: iss '${payload.iss}' does not match expected issuer '${issuerIdentifier}'`) + } + + // Step 5 + if (payload.exp <= nowEpochSeconds()) { + throw new Error(`Step 5 failed: JWT expired at ${payload.exp}`) + } + + if (expectedCredentialMetadataUri && payload.credential_metadata_uri !== expectedCredentialMetadataUri) { + throw new Error( + `credential_metadata_uri mismatch: payload contains '${payload.credential_metadata_uri}' but was fetched from '${expectedCredentialMetadataUri}'` + ) + } + + // Step 6a + if (payload.sub !== credentialType) { + throw new Error(`Step 6a failed: sub '${payload.sub}' does not match credential type '${credentialType}'`) + } + + // Step 6b+6c + const metadataLeaf = X509Certificate.fromEncodedCertificate(header.x5c[0]) + const credentialLeaf = X509Certificate.fromEncodedCertificate(credentialX5c[0]) + const metadataRoot = + header.x5c.length === 1 ? metadataLeaf : X509Certificate.fromEncodedCertificate(header.x5c[header.x5c.length - 1]) + const credentialRoot = + credentialX5c.length === 1 + ? credentialLeaf + : X509Certificate.fromEncodedCertificate(credentialX5c[credentialX5c.length - 1]) + + if (metadataRoot.subject !== credentialRoot.subject) { + throw new Error( + `Step 6b failed: root CA subject '${metadataRoot.subject}' does not match credential root CA '${credentialRoot.subject}'` + ) + } + + if (metadataLeaf.subject !== credentialLeaf.subject) { + throw new Error( + `Step 6c failed: leaf Subject '${metadataLeaf.subject}' does not match credential leaf Subject '${credentialLeaf.subject}'` + ) + } + + const metadataIntegrity = this.computeMetadataIntegrity(compactJwt) + return { header, payload, compactJwt, metadataIntegrity } + } + + private async fetchFromUri(uri: string, accept: string, acceptLanguage?: string): Promise { + const headers: Record = { Accept: accept } + if (acceptLanguage) headers['Accept-Language'] = acceptLanguage + + const response = await fetch(uri, { headers }) + if (!response.ok) { + throw new Error(`Failed to fetch credential metadata from '${uri}': HTTP ${response.status}`) + } + return response + } + + private async fetchCredentialMetadataJwt(credentialMetadataUri: string, acceptLanguage?: string): Promise { + const response = await this.fetchFromUri(credentialMetadataUri, CREDENTIAL_METADATA_JWT_MEDIA_TYPE, acceptLanguage) + const body = await response.text() + if (!body || body.split('.').length !== 3) { + throw new Error(`Response from '${credentialMetadataUri}' is not a valid compact JWT`) + } + return body + } + + private async fetchUnsignedCredentialMetadata( + credentialMetadataUri: string, + acceptLanguage?: string + ): Promise { + const response = await this.fetchFromUri(credentialMetadataUri, 'application/json', acceptLanguage) + const json = await response.json() + const result = zCredentialMetadata.safeParse(json) + if (!result.success) { + throw new Error(`Unsigned credential metadata validation failed — ${result.error.message}`) + } + return result.data + } +} diff --git a/packages/credential-metadata-query/src/index.ts b/packages/credential-metadata-query/src/index.ts new file mode 100644 index 0000000..79d516a --- /dev/null +++ b/packages/credential-metadata-query/src/index.ts @@ -0,0 +1,6 @@ +export * from './credential-metadata-query-api' +export * from './credential-metadata-query-module' +export * from './credential-metadata-query-service' +export * from './repository/credential-metadata-jwt-record' +export * from './repository/credential-metadata-jwt-repository' +export * from './types' diff --git a/packages/credential-metadata-query/src/repository/credential-metadata-jwt-record.ts b/packages/credential-metadata-query/src/repository/credential-metadata-jwt-record.ts new file mode 100644 index 0000000..d8c631d --- /dev/null +++ b/packages/credential-metadata-query/src/repository/credential-metadata-jwt-record.ts @@ -0,0 +1,75 @@ +import { BaseRecord, type TagsBase, utils } from '@credo-ts/core' + +type DefaultCredentialMetadataJwtRecordTags = { + /** Links to the credential record (SdJwtVcRecord/MdocRecord) by id. */ + credentialRecordId: string + /** The credential type identifier (vct or doctype) for querying. */ + credentialType: string + /** The Credential Issuer Identifier. */ + issuerIdentifier: string +} + +export type CredentialMetadataJwtRecordProps = { + id?: string + createdAt?: Date + tags?: TagsBase + + /** The raw signed JWT — persisted in signed form per Section 4.1.3. */ + compactJwt: string + /** The credential_metadata_uri for re-fetch (Section 4.1.4). */ + credentialMetadataUri: string + /** The Credential Issuer Identifier (for step 4 verification). */ + issuerIdentifier: string + /** The credential type identifier — vct or doctype (for step 6a). */ + credentialType: string + /** The credential format identifier (e.g., 'dc+sd-jwt', 'mso_mdoc'). */ + format: string + /** The exp claim as epoch seconds, for renewal checks (Section 4.1.4). */ + expiresAtSeconds: number + /** The id of the linked credential record. */ + credentialRecordId: string +} + +/** + * TS12 Section 4.1.3 — Persisted signed credential metadata JWT. + * + * Each record is linked to a credential record (SdJwtVcRecord/MdocRecord) + * via the `credentialRecordId` tag. The Wallet Unit SHALL persist the signed + * credential metadata JWT in its signed form and SHALL NOT persist the decoded + * credential metadata. + */ +export class CredentialMetadataJwtRecord extends BaseRecord { + static readonly type = 'CredentialMetadataJwtRecord' as const + readonly type = CredentialMetadataJwtRecord.type + + compactJwt!: string + credentialMetadataUri!: string + issuerIdentifier!: string + credentialType!: string + format!: string + expiresAtSeconds!: number + credentialRecordId!: string + + constructor(props: CredentialMetadataJwtRecordProps) { + super() + this.id = props.id ?? utils.uuid() + this.createdAt = props.createdAt ?? new Date() + this._tags = props.tags ?? {} + this.compactJwt = props.compactJwt + this.credentialMetadataUri = props.credentialMetadataUri + this.issuerIdentifier = props.issuerIdentifier + this.credentialType = props.credentialType + this.format = props.format + this.expiresAtSeconds = props.expiresAtSeconds + this.credentialRecordId = props.credentialRecordId + } + + getTags() { + return { + ...this._tags, + credentialRecordId: this.credentialRecordId, + credentialType: this.credentialType, + issuerIdentifier: this.issuerIdentifier, + } + } +} diff --git a/packages/credential-metadata-query/src/repository/credential-metadata-jwt-repository.ts b/packages/credential-metadata-query/src/repository/credential-metadata-jwt-repository.ts new file mode 100644 index 0000000..15d380a --- /dev/null +++ b/packages/credential-metadata-query/src/repository/credential-metadata-jwt-repository.ts @@ -0,0 +1,28 @@ +// biome-ignore lint/style/useImportType: DI requires runtime class references for EventEmitter and StorageService +import { type AgentContext, EventEmitter, Repository, StorageService } from '@credo-ts/core' +import { CredentialMetadataJwtRecord } from './credential-metadata-jwt-record' + +export class CredentialMetadataJwtRepository extends Repository { + constructor(storageService: StorageService, eventEmitter: EventEmitter) { + super(CredentialMetadataJwtRecord, storageService, eventEmitter) + } + + async findByCredentialRecordId( + agentContext: AgentContext, + credentialRecordId: string + ): Promise { + return this.findSingleByQuery(agentContext, { credentialRecordId }) + } + + /** @throws if no metadata is stored for this credential. */ + async getByCredentialRecordId( + agentContext: AgentContext, + credentialRecordId: string + ): Promise { + const record = await this.findByCredentialRecordId(agentContext, credentialRecordId) + if (!record) { + throw new Error(`No credential metadata JWT found for credential record '${credentialRecordId}'`) + } + return record + } +} diff --git a/packages/credential-metadata-query/src/types.ts b/packages/credential-metadata-query/src/types.ts new file mode 100644 index 0000000..f87ad23 --- /dev/null +++ b/packages/credential-metadata-query/src/types.ts @@ -0,0 +1,59 @@ +import type { + CredentialMetadata, + CredentialMetadataJwtHeader, + CredentialMetadataJwtPayload, +} from '@animo-id/eudi-wallet-ts12-validation' + +/** The result of successful credential metadata JWT verification (Section 4.1.2). */ +export interface VerifiedCredentialMetadata { + header: CredentialMetadataJwtHeader + payload: CredentialMetadataJwtPayload + compactJwt: string + /** W3C SRI integrity value (Section 3.7.1). */ + metadataIntegrity: string +} + +/** Credential verification context for signed JWT operations. */ +export interface CredentialVerificationContext { + credentialX5c: string[] + trustedCertificates?: string[] +} + +export interface FetchAndStoreOptions extends CredentialVerificationContext { + credentialMetadataUri: string + issuerIdentifier: string + credentialType: string + credentialRecordId: string + acceptLanguage?: string +} + +export interface GetVerifiedMetadataOptions extends CredentialVerificationContext { + credentialRecordId: string +} + +export interface RenewIfNeededOptions extends CredentialVerificationContext { + credentialRecordId: string + /** Seconds before expiry to trigger renewal. Default: 3600 (1 hour). */ + thresholdSeconds?: number +} + +/** Unified result of credential metadata resolution. */ +export type ResolvedCredentialMetadata = + | ({ source: 'signed-jwt'; credentialMetadata: CredentialMetadata } & VerifiedCredentialMetadata) + | { source: 'unsigned-json'; credentialMetadata: CredentialMetadata } + | { source: 'inline'; credentialMetadata: CredentialMetadata } + +/** Options for the unified resolution entry point. */ +export interface ResolveCredentialMetadataOptions { + credentialRecordId: string + issuerIdentifier: string + credentialType: string + /** From credential_configurations_supported. If present, fetches from URI. */ + credentialMetadataUri?: string + /** Inline credential_metadata object. Used when credentialMetadataUri is absent. */ + credentialMetadata?: CredentialMetadata + /** Required when credentialMetadataUri is present (for signed JWT verification). */ + credentialX5c?: string[] + trustedCertificates?: string[] + acceptLanguage?: string +} diff --git a/packages/credential-metadata-query/tsconfig.json b/packages/credential-metadata-query/tsconfig.json new file mode 100644 index 0000000..f8b80bc --- /dev/null +++ b/packages/credential-metadata-query/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "noEmitOnError": true, + "lib": ["ES2022"], + "types": [], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["dist"] +} diff --git a/packages/dcql/README.md b/packages/dcql/README.md new file mode 100644 index 0000000..02e9bea --- /dev/null +++ b/packages/dcql/README.md @@ -0,0 +1,115 @@ +# @animo-id/eudi-wallet-ts12-dcql + +DCQL credential set resolution with TS12 transposability verification. + +Takes a DCQL query + transaction data from an OID4VP authorization request and resolves it into independent UI slots, each with locale-resolved credential alternatives. + +## Install + +```bash +pnpm add @animo-id/eudi-wallet-ts12-dcql +``` + +## Resolve a DCQL query + +```ts +import { resolveDcql } from '@animo-id/eudi-wallet-ts12-dcql' +import type { + DcqlQuery, + TransactionDataInput, + WalletConfiguration, + CredentialMatcher, + MatchedCredential, +} from '@animo-id/eudi-wallet-ts12-dcql' + +// The DCQL query from the OID4VP request +const dcqlQuery: DcqlQuery = { + credentials: [ + { id: 'payment_card', format: 'dc+sd-jwt', meta: { vct_values: ['https://pay.example.com/card'] } }, + { id: 'pid', format: 'dc+sd-jwt', meta: { vct_values: ['https://example.com/pid'] } }, + ], + credential_sets: [ + { options: [['payment_card', 'pid']] }, + ], +} + +// Transaction data entries from the same request +const transactionData: TransactionDataInput[] = [ + { + type: 'urn:eudi:sca:eu.europa.ec:payment:single:1', + credential_ids: ['payment_card'], + payload: { payee_name: 'Coffee Shop', amount: '4.50' }, + }, +] + +// Wallet configuration +const config: WalletConfiguration = { + locales: ['en', 'de'], + valueTypeResolvers: { + currency_amount: (raw, locale) => `${raw} EUR`, + string: (raw) => raw, + }, + mode: 'light', +} + +// Maps DCQL credential queries to wallet credentials +const matchCredentials: CredentialMatcher = (query) => { + // Return matched credentials from the wallet store + return walletStore.findByFormat(query.format, query.meta) +} + +const result = resolveDcql(dcqlQuery, transactionData, matchCredentials, config) + +if (result.ok) { + result.value.locale // 'en' — selected locale for the presentation + result.value.credentialSets // resolved credential sets with slots + // + // Each credential set contains independent slots: + // slot.alternatives[0].credentialQueryId — which DCQL query this maps to + // slot.alternatives[0].credentials — matched wallet credentials with: + // .display — locale-resolved credential card metadata + // .transactionData?.resolved — locale-resolved transaction display (claims + UI labels) +} +``` + +## SCA vs non-SCA + +`resolveDcql` automatically detects whether the request involves SCA based on `config.scaTypeMatcher` (defaults to matching `urn:eudi:sca:` prefix): + +- **SCA present**: strict TS12 rules — locale must satisfy all display arrays, SCA-targeted options must be transposable (Section 3.4). Returns `Err` on failure. +- **No SCA**: best-effort — decomposition never errors, locale is best-effort. + +Override the matcher via `WalletConfiguration.scaTypeMatcher` to support additional standards: + +```ts +import { createScaTypeMatcher } from '@animo-id/eudi-wallet-ts12-validation' + +const config: WalletConfiguration = { + // ... + scaTypeMatcher: createScaTypeMatcher('urn:eudi:sca:'), +} +``` + +## Key types + +```ts +// Input: what the wallet provides for each credential query +interface MatchedCredential { + credentialId: string + scaMetadata?: ScaCredentialMetadata // if this is an SCA Attestation + display?: CredentialDisplayEntry[] // OID4VCI credential display +} + +// Output: fully resolved credential ready for UI +interface ResolvedWalletCredential { + credentialId: string + credentialQueryId: string + display?: ResolvedCredentialDisplay + requestedClaims?: DcqlClaimsQuery[] + transactionData?: { + index: number + entry: TransactionDataInput + resolved?: ResolvedTransactionDisplay // present for SCA entries + } +} +``` diff --git a/packages/dcql/package.json b/packages/dcql/package.json new file mode 100644 index 0000000..00b3af2 --- /dev/null +++ b/packages/dcql/package.json @@ -0,0 +1,41 @@ +{ + "name": "@animo-id/eudi-wallet-ts12-dcql", + "description": "EUDI Wallet TS12 DCQL credential set resolution with transposability verification", + "version": "0.1.0", + "license": "Apache-2.0", + "author": "Frederic Artus Nieto for DSGV", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/animo/eudi-wallet-functionality", + "directory": "packages/dcql" + }, + "scripts": { + "types:check": "tsc --noEmit", + "build": "tsdown src/index.ts --format esm --dts --clean --sourcemap" + }, + "dependencies": { + "@animo-id/eudi-wallet-ts12-validation": "workspace:*", + "@animo-id/eudi-wallet-ts12-resolver": "workspace:*" + }, + "devDependencies": { + "tsdown": "^0.18.4", + "typescript": "~5.9.3" + } +} diff --git a/packages/dcql/src/cartesian.ts b/packages/dcql/src/cartesian.ts new file mode 100644 index 0000000..8714e72 --- /dev/null +++ b/packages/dcql/src/cartesian.ts @@ -0,0 +1,196 @@ +/** + * Build the set of ID pairs that co-occur in at least one alternative. + * Keys are normalized as `min\0max` for consistent lookup. + */ +export function buildCoOccurrences(alternatives: string[][]): Set { + const pairs = new Set() + for (const alt of alternatives) { + for (let i = 0; i < alt.length; i++) { + for (let j = i + 1; j < alt.length; j++) { + pairs.add(pairKey(alt[i], alt[j])) + } + } + } + return pairs +} + +function pairKey(a: string, b: string): string { + return a < b ? `${a}\0${b}` : `${b}\0${a}` +} + +/** + * Check if two IDs co-occur in any alternative. + */ +function coOccurs(a: string, b: string, pairs: Set): boolean { + return pairs.has(pairKey(a, b)) +} + +export interface SlotDecomposition { + ids: string[] + optional: boolean +} + +/** + * Assign IDs to slots using co-occurrence analysis. + * + * For a valid cartesian product the co-occurrence graph is complete multipartite, + * so greedy assignment always finds the correct partition: + * - IDs that co-occur must be in different slots. + * - IDs that never co-occur are placed in the same slot. + * + * IDs are processed in first-appearance order across alternatives, + * so slot order reflects RP preference per TS12 Section 3.4. + */ +export function assignSlots(alternatives: string[][]): SlotDecomposition[] { + const seen = new Set() + const ordered: string[] = [] + for (const alt of alternatives) { + for (const id of alt) { + if (!seen.has(id)) { + seen.add(id) + ordered.push(id) + } + } + } + + const coOccurrences = buildCoOccurrences(alternatives) + const slots: string[][] = [] + const slotOf = new Map() + + for (const id of ordered) { + let placed = false + for (let s = 0; s < slots.length; s++) { + if (slots[s].every((existing) => !coOccurs(id, existing, coOccurrences))) { + slots[s].push(id) + slotOf.set(id, s) + placed = true + break + } + } + if (!placed) { + slotOf.set(id, slots.length) + slots.push([id]) + } + } + + return slots.map((ids) => ({ + ids, + optional: alternatives.some((alt) => !ids.some((id) => alt.includes(id))), + })) +} + +/** + * Generate the cartesian product of slots. + * Optional slots contribute a ∅ choice (omitted from the resulting tuple). + */ +export function generateCartesianProduct(slots: SlotDecomposition[]): string[][] { + if (slots.length === 0) return [[]] + + const [first, ...rest] = slots + const restProduct = generateCartesianProduct(rest) + const choices: (string | null)[] = first.optional ? [...first.ids, null] : [...first.ids] + const result: string[][] = [] + + for (const choice of choices) { + for (const tail of restProduct) { + result.push(choice === null ? [...tail] : [choice, ...tail]) + } + } + + return result +} + +/** Normalize an alternative to a canonical string for set comparison. */ +function normalizeAlt(alt: string[]): string { + return [...alt].sort().join('\0') +} + +/** + * Verify that a set of alternatives equals the cartesian product of the given slots. + */ +export function verifyCartesianProduct(slots: SlotDecomposition[], alternatives: string[][]): boolean { + const expected = generateCartesianProduct(slots) + if (expected.length !== alternatives.length) return false + + const expectedSet = new Set(expected.map(normalizeAlt)) + return alternatives.every((alt) => expectedSet.has(normalizeAlt(alt))) +} + +/** + * TS12 Section 3.4 — Decompose alternatives into slots and verify transposability. + * + * Returns the slot decomposition if the alternatives form a valid cartesian product, + * or `undefined` if they do not (not transposable). + */ +export function decomposeTransposable(alternatives: string[][]): SlotDecomposition[] | undefined { + if (alternatives.length === 0) return [] + + const slots = assignSlots(alternatives) + if (!verifyCartesianProduct(slots, alternatives)) return undefined + + return slots +} + +/** + * Best-effort decomposition for non-SCA alternatives. + * + * Uses the first alternative as the reference structure. Subsequent alternatives + * that fit the slot pattern are included; those that don't are skipped. + * Returns the largest valid cartesian product found. + */ +export function bestEffortDecompose(alternatives: string[][]): SlotDecomposition[] { + if (alternatives.length === 0) return [] + + // Try exact decomposition first + const exact = decomposeTransposable(alternatives) + if (exact) return exact + + // Fall back: use first alternative as structure, greedily add fitting alternatives + const first = alternatives[0] + const slots: string[][] = first.map((id) => [id]) + const slotOf = new Map() + for (let i = 0; i < first.length; i++) { + slotOf.set(first[i], i) + } + + const accepted = [first] + + for (const alt of alternatives.slice(1)) { + const used = new Set() + let fits = true + + for (const id of alt) { + const s = slotOf.get(id) + if (s !== undefined) { + if (used.has(s)) { + fits = false + break + } + used.add(s) + } else { + // New ID: find an unused slot with no co-occurrence conflict + let placed = false + for (let s = 0; s < slots.length; s++) { + if (!used.has(s) && slots[s].every((e) => !accepted.some((a) => a.includes(e) && a.includes(id)))) { + slotOf.set(id, s) + slots[s].push(id) + used.add(s) + placed = true + break + } + } + if (!placed) { + fits = false + break + } + } + } + + if (fits) accepted.push(alt) + } + + return slots.map((ids) => ({ + ids, + optional: accepted.some((alt) => !ids.some((id) => alt.includes(id))), + })) +} diff --git a/packages/dcql/src/index.ts b/packages/dcql/src/index.ts new file mode 100644 index 0000000..8e9825e --- /dev/null +++ b/packages/dcql/src/index.ts @@ -0,0 +1,6 @@ +export * from './cartesian' +export * from './resolve-credential-set' +export * from './resolve-credentials' +export * from './resolve-dcql' +export * from './result' +export * from './types' diff --git a/packages/dcql/src/resolve-credential-set.ts b/packages/dcql/src/resolve-credential-set.ts new file mode 100644 index 0000000..d0d9581 --- /dev/null +++ b/packages/dcql/src/resolve-credential-set.ts @@ -0,0 +1,217 @@ +import { defaultScaTypeMatcher } from '@animo-id/eudi-wallet-ts12-validation' +import { bestEffortDecompose, decomposeTransposable, type SlotDecomposition } from './cartesian' +import { resolveAllMatchedCredentials } from './resolve-credentials' +import { err, ok, type Result } from './result' +import type { + CredentialMatcher, + DcqlCredentialQuery, + DcqlCredentialSetQuery, + ResolvedCredentialSet, + ResolvedSlot, + SlotAlternative, + TransactionDataInput, + WalletConfiguration, +} from './types' + +// ============================================================================= +// Shared helpers +// ============================================================================= + +/** + * Check whether an option is satisfiable: the wallet has at least one matching + * credential for every DCQL query in the option. + */ +export function isOptionSatisfiable( + option: string[], + queries: Map, + matchCredentials: CredentialMatcher +): boolean { + return option.every((id) => { + const query = queries.get(id) + return query ? matchCredentials(query).length > 0 : false + }) +} + +/** + * Find the first satisfiable option in the credential set. + */ +export function findFirstSatisfiableOption( + options: string[][], + queries: Map, + matchCredentials: CredentialMatcher +): string[] | undefined { + return options.find((opt) => isOptionSatisfiable(opt, queries, matchCredentials)) +} + +/** + * Order slots so their position matches the ID order in the reference option. + */ +export function orderSlotsByReference(slots: SlotDecomposition[], referenceOption: string[]): SlotDecomposition[] { + return [...slots].sort((a, b) => { + const posA = Math.min( + ...a.ids.map((id) => { + const idx = referenceOption.indexOf(id) + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx + }) + ) + const posB = Math.min( + ...b.ids.map((id) => { + const idx = referenceOption.indexOf(id) + return idx === -1 ? Number.MAX_SAFE_INTEGER : idx + }) + ) + return posA - posB + }) +} + +/** + * Build a ResolvedSlot from a slot decomposition for a given locale. + */ +export function buildResolvedSlot( + slot: SlotDecomposition, + queries: Map, + transactionData: TransactionDataInput[], + matchCredentials: CredentialMatcher, + locale: string, + config: WalletConfiguration +): ResolvedSlot { + const alternatives: SlotAlternative[] = slot.ids.map((credentialQueryId) => { + const query = queries.get(credentialQueryId) + const matched = query ? matchCredentials(query) : [] + const credentials = resolveAllMatchedCredentials( + matched, + credentialQueryId, + query?.claims, + transactionData, + locale, + config + ) + return { credentialQueryId, credentials } + }) + + return { optional: slot.optional, alternatives } +} + +// ============================================================================= +// SCA — strict TS12 resolution +// ============================================================================= + +/** + * Collect the set of DCQL credential query IDs referenced by SCA transaction_data entries. + */ +export function collectScaCredentialQueryIds( + transactionData: TransactionDataInput[], + config: WalletConfiguration +): Set { + const isScaType = config.scaTypeMatcher ?? defaultScaTypeMatcher + const ids = new Set() + for (const td of transactionData) { + if (isScaType(td.type)) { + for (const id of td.credential_ids) ids.add(id) + } + } + return ids +} + +/** + * Partition options into SCA-targeted and non-SCA groups. + */ +export function partitionOptions( + options: string[][], + scaCredentialQueryIds: Set +): { sca: string[][]; nonSca: string[][] } { + const sca: string[][] = [] + const nonSca: string[][] = [] + for (const opt of options) { + if (opt.some((id) => scaCredentialQueryIds.has(id))) { + sca.push(opt) + } else { + nonSca.push(opt) + } + } + return { sca, nonSca } +} + +/** + * Resolve a credential set under strict TS12 rules. + * + * SCA-targeted options MUST be transposable (TS12 Section 3.4) — returns `Err` if not. + * Non-SCA options within the same set use best-effort decomposition. + */ +export function resolveScaCredentialSet( + credentialSet: DcqlCredentialSetQuery, + queries: Map, + transactionData: TransactionDataInput[], + matchCredentials: CredentialMatcher, + locale: string, + config: WalletConfiguration +): Result { + const { options, description } = credentialSet + const required = credentialSet.required !== false + + const firstSatisfiable = findFirstSatisfiableOption(options, queries, matchCredentials) + const scaQueryIds = collectScaCredentialQueryIds(transactionData, config) + const { sca, nonSca } = partitionOptions(options, scaQueryIds) + + // SCA options: strict transposability + let scaSlots: SlotDecomposition[] = [] + if (sca.length > 0) { + const decomposed = decomposeTransposable(sca) + if (!decomposed) { + return err('SCA-targeted options are not transposable (TS12 Section 3.4)') + } + scaSlots = decomposed + } + + // Non-SCA options within the same set: best-effort + const nonScaSlots = bestEffortDecompose(nonSca) + + // Merge: SCA slots first, then non-SCA slots with new IDs only + const allScaIds = new Set(scaSlots.flatMap((s) => s.ids)) + const mergedSlots = [...scaSlots] + for (const slot of nonScaSlots) { + const newIds = slot.ids.filter((id) => !allScaIds.has(id)) + if (newIds.length > 0) { + mergedSlots.push({ ids: newIds, optional: slot.optional }) + } + } + + const ordered = firstSatisfiable ? orderSlotsByReference(mergedSlots, firstSatisfiable) : mergedSlots + const slots = ordered.map((slot) => + buildResolvedSlot(slot, queries, transactionData, matchCredentials, locale, config) + ) + + return ok({ description, required, slots }) +} + +// ============================================================================= +// Non-SCA — best-effort resolution +// ============================================================================= + +/** + * Resolve a credential set with best-effort decomposition. + * + * No transposability requirement — uses `bestEffortDecompose` on all options. + * Never returns `Err` for decomposition issues. + */ +export function resolveNonScaCredentialSet( + credentialSet: DcqlCredentialSetQuery, + queries: Map, + transactionData: TransactionDataInput[], + matchCredentials: CredentialMatcher, + locale: string, + config: WalletConfiguration +): ResolvedCredentialSet { + const { options, description } = credentialSet + const required = credentialSet.required !== false + + const firstSatisfiable = findFirstSatisfiableOption(options, queries, matchCredentials) + const decomposed = bestEffortDecompose(options) + const ordered = firstSatisfiable ? orderSlotsByReference(decomposed, firstSatisfiable) : decomposed + + const slots = ordered.map((slot) => + buildResolvedSlot(slot, queries, transactionData, matchCredentials, locale, config) + ) + + return { description, required, slots } +} diff --git a/packages/dcql/src/resolve-credentials.ts b/packages/dcql/src/resolve-credentials.ts new file mode 100644 index 0000000..c8865ee --- /dev/null +++ b/packages/dcql/src/resolve-credentials.ts @@ -0,0 +1,203 @@ +import type { ResolvedTransactionDisplay } from '@animo-id/eudi-wallet-ts12-resolver' +import { resolveTransactionDisplay, selectLocaleEntry } from '@animo-id/eudi-wallet-ts12-resolver' +import type { ScaCredentialMetadata, ScaTransactionDataEntry } from '@animo-id/eudi-wallet-ts12-validation' +import { defaultScaTypeMatcher } from '@animo-id/eudi-wallet-ts12-validation' +import type { + CredentialDisplayEntry, + DcqlClaimsQuery, + MatchedCredential, + ResolvedCredentialDisplay, + ResolvedWalletCredential, + TransactionDataInput, + WalletConfiguration, +} from './types' + +/** + * Resolve credential display metadata for a single locale. + * Selects the locale-matched SVG template if the wallet supports it. + */ +export function resolveCredentialDisplay( + display: CredentialDisplayEntry[], + locale: string, + _config: WalletConfiguration +): ResolvedCredentialDisplay | undefined { + const entry = selectLocaleEntry(display, locale) + if (!entry) return undefined + + return { + name: entry.name, + description: entry.description, + logo: entry.logo, + background_color: entry.background_color, + text_color: entry.text_color, + } +} + +/** + * TS12 Section 3.3 step 3 — First-match rule for SCA transaction data. + */ +export function resolveFirstMatchScaTransactionData( + scaMetadata: ScaCredentialMetadata, + credentialQueryId: string, + transactionData: TransactionDataInput[], + locale: string, + config: WalletConfiguration +): { index: number; entry: TransactionDataInput; resolved: ResolvedTransactionDisplay } | undefined { + const isScaType = config.scaTypeMatcher ?? defaultScaTypeMatcher + for (let i = 0; i < transactionData.length; i++) { + const td = transactionData[i] + if (!isScaType(td.type)) continue + if (!td.credential_ids.includes(credentialQueryId)) continue + + const resolved = resolveTransactionDisplay( + td as ScaTransactionDataEntry, + scaMetadata, + locale, + config.valueTypeResolvers + ) + if (resolved) return { index: i, entry: td, resolved } + } + return undefined +} + +/** + * Find the first non-SCA transaction_data entry targeting a credential query ID + * whose type is supported by the credential. + * + * Uses `config.checkNonScaTransactionDataSupport` to verify compatibility. + * If the check function is absent, no non-SCA entry can match (incompatible). + */ +export function findFirstNonScaTransactionData( + credentialId: string, + credentialQueryId: string, + transactionData: TransactionDataInput[], + config: WalletConfiguration +): { index: number; entry: TransactionDataInput } | undefined { + if (!config.checkNonScaTransactionDataSupport) return undefined + + const isScaType = config.scaTypeMatcher ?? defaultScaTypeMatcher + for (let i = 0; i < transactionData.length; i++) { + const td = transactionData[i] + if (isScaType(td.type)) continue + if (!td.credential_ids.includes(credentialQueryId)) continue + if (!config.checkNonScaTransactionDataSupport(credentialId, td.type)) continue + return { index: i, entry: td } + } + return undefined +} + +/** + * Find the first matching transaction_data entry for a credential. + * Tries SCA entries first (with full resolution), then non-SCA (with support check). + */ +export function resolveTransactionDataForCredential( + credential: MatchedCredential, + credentialQueryId: string, + transactionData: TransactionDataInput[], + locale: string, + config: WalletConfiguration +): { index: number; entry: TransactionDataInput; resolved?: ResolvedTransactionDisplay } | undefined { + if (credential.scaMetadata) { + const sca = resolveFirstMatchScaTransactionData( + credential.scaMetadata, + credentialQueryId, + transactionData, + locale, + config + ) + if (sca) return sca + } + + return findFirstNonScaTransactionData(credential.credentialId, credentialQueryId, transactionData, config) +} + +/** + * Check if a credential's display arrays can be locale-resolved for a given locale. + */ +export function canResolveCredentialForLocale( + credential: MatchedCredential, + credentialQueryId: string, + transactionData: TransactionDataInput[], + locale: string, + config: WalletConfiguration +): boolean { + if (credential.display && credential.display.length > 0) { + if (!selectLocaleEntry(credential.display, locale)) return false + } + + const isScaType = config.scaTypeMatcher ?? defaultScaTypeMatcher + if (credential.scaMetadata) { + const hasScaEntry = transactionData.some( + (td) => isScaType(td.type) && td.credential_ids.includes(credentialQueryId) + ) + if (hasScaEntry) { + if ( + !resolveFirstMatchScaTransactionData(credential.scaMetadata, credentialQueryId, transactionData, locale, config) + ) { + return false + } + } + } + + return true +} + +/** + * Fully resolve a single wallet credential for a given locale. + */ +export function resolveWalletCredential( + credential: MatchedCredential, + credentialQueryId: string, + requestedClaims: DcqlClaimsQuery[] | undefined, + transactionData: TransactionDataInput[], + locale: string, + config: WalletConfiguration +): ResolvedWalletCredential { + const display = credential.display ? resolveCredentialDisplay(credential.display, locale, config) : undefined + const td = resolveTransactionDataForCredential(credential, credentialQueryId, transactionData, locale, config) + + return { + credentialId: credential.credentialId, + credentialQueryId, + display, + requestedClaims, + transactionData: td, + } +} + +/** + * Check whether a credential query ID is targeted by any transaction_data entry. + */ +export function isTargetedByTransactionData( + credentialQueryId: string, + transactionData: TransactionDataInput[] +): boolean { + return transactionData.some((td) => td.credential_ids.includes(credentialQueryId)) +} + +/** + * Resolve all matched credentials for a DCQL credential query ID. + * + * If the query is targeted by transaction_data, credentials that fail to resolve + * any matching transaction_data entry are excluded — they cannot fulfil the + * transaction requirement. + */ +export function resolveAllMatchedCredentials( + matchedCredentials: MatchedCredential[], + credentialQueryId: string, + requestedClaims: DcqlClaimsQuery[] | undefined, + transactionData: TransactionDataInput[], + locale: string, + config: WalletConfiguration +): ResolvedWalletCredential[] { + const targeted = isTargetedByTransactionData(credentialQueryId, transactionData) + + const resolved = matchedCredentials.map((cred) => + resolveWalletCredential(cred, credentialQueryId, requestedClaims, transactionData, locale, config) + ) + + if (!targeted) return resolved + + // Only keep credentials where transaction data resolved + return resolved.filter((cred) => cred.transactionData !== undefined) +} diff --git a/packages/dcql/src/resolve-dcql.ts b/packages/dcql/src/resolve-dcql.ts new file mode 100644 index 0000000..7fc4781 --- /dev/null +++ b/packages/dcql/src/resolve-dcql.ts @@ -0,0 +1,241 @@ +import { defaultScaTypeMatcher } from '@animo-id/eudi-wallet-ts12-validation' +import { resolveNonScaCredentialSet, resolveScaCredentialSet } from './resolve-credential-set' +import { canResolveCredentialForLocale } from './resolve-credentials' +import { err, isErr, ok, type Result } from './result' +import type { + CredentialMatcher, + DcqlCredentialQuery, + DcqlCredentialSetQuery, + DcqlQuery, + MatchedCredential, + ResolvedCredentialSet, + ResolvedDcqlResult, + TransactionDataInput, + WalletConfiguration, +} from './types' + +/** + * Build a lookup map from credential query ID to query object. + */ +/** + * Build a lookup map from credential query ID to query object. + * Returns undefined if duplicate IDs are found (OID4VP §6.1: "the same id MUST NOT be present more than once"). + */ +export function buildCredentialQueryMap( + credentials: DcqlCredentialQuery[] +): Map | undefined { + const map = new Map() + for (const q of credentials) { + if (map.has(q.id)) return undefined + map.set(q.id, q) + } + return map +} + +/** + * Check whether any transaction_data entry is SCA-compatible per the given matcher. + */ +export function hasScaTransactionData(transactionData: TransactionDataInput[], config: WalletConfiguration): boolean { + const isScaType = config.scaTypeMatcher ?? defaultScaTypeMatcher + return transactionData.some((td) => isScaType(td.type)) +} + +/** + * Collect all (credentialQueryId, matchedCredential) pairs across all credential sets. + */ +function collectAllMatchedCredentials( + credentialSets: DcqlCredentialSetQuery[], + queries: Map, + matchCredentials: CredentialMatcher +): Array<{ credentialQueryId: string; credential: MatchedCredential }> { + const allIds = new Set() + for (const cs of credentialSets) { + for (const opt of cs.options) { + for (const id of opt) allIds.add(id) + } + } + + const result: Array<{ credentialQueryId: string; credential: MatchedCredential }> = [] + for (const id of allIds) { + const query = queries.get(id) + if (!query) continue + for (const cred of matchCredentials(query)) { + result.push({ credentialQueryId: id, credential: cred }) + } + } + return result +} + +// ============================================================================= +// SCA path — strict TS12 rules +// ============================================================================= + +function canResolveAllForLocale( + allMatched: Array<{ credentialQueryId: string; credential: MatchedCredential }>, + transactionData: TransactionDataInput[], + locale: string, + config: WalletConfiguration +): boolean { + return allMatched.every(({ credentialQueryId, credential }) => + canResolveCredentialForLocale(credential, credentialQueryId, transactionData, locale, config) + ) +} + +function resolveScaDcql( + credentialSets: DcqlCredentialSetQuery[], + queries: Map, + transactionData: TransactionDataInput[], + matchCredentials: CredentialMatcher, + config: WalletConfiguration +): Result { + const allMatched = collectAllMatchedCredentials(credentialSets, queries, matchCredentials) + + for (const locale of config.locales) { + if (!canResolveAllForLocale(allMatched, transactionData, locale, config)) { + continue + } + + const resolved: ResolvedCredentialSet[] = [] + for (const cs of credentialSets) { + const result = resolveScaCredentialSet(cs, queries, transactionData, matchCredentials, locale, config) + if (isErr(result)) return err(result.error) + resolved.push(result.value) + } + + return ok({ locale, credentialSets: resolved }) + } + + return err('No locale from the priority list could satisfy all display arrays in the SCA request') +} + +// ============================================================================= +// Non-SCA path — best-effort +// ============================================================================= + +function resolveNonScaDcql( + credentialSets: DcqlCredentialSetQuery[], + queries: Map, + transactionData: TransactionDataInput[], + matchCredentials: CredentialMatcher, + config: WalletConfiguration +): Result { + const allMatched = collectAllMatchedCredentials(credentialSets, queries, matchCredentials) + + let selectedLocale = config.locales[0] + for (const locale of config.locales) { + const allDisplayOk = allMatched.every(({ credential }) => { + if (!credential.display || credential.display.length === 0) return true + return credential.display.some((d) => !d.locale) || credential.display.some((d) => d.locale !== undefined) + }) + if (allDisplayOk) { + selectedLocale = locale + break + } + } + + const resolved: ResolvedCredentialSet[] = [] + for (const cs of credentialSets) { + resolved.push(resolveNonScaCredentialSet(cs, queries, transactionData, matchCredentials, selectedLocale, config)) + } + + return ok({ locale: selectedLocale, credentialSets: resolved }) +} + +// ============================================================================= +// Entry point +// ============================================================================= + +/** + * OID4VP §6.1, §6.4.1 — Validate DCQL query structural constraints. + * + * - §6.1: "the same id MUST NOT be present more than once" + * - §6.4.1: "claim_sets MUST NOT be present if claims is absent" + */ +export function validateDcqlQueryStructure(dcqlQuery: DcqlQuery): string | undefined { + const seenIds = new Set() + for (const q of dcqlQuery.credentials) { + if (seenIds.has(q.id)) { + return `Duplicate credential query id '${q.id}' (OID4VP §6.1)` + } + seenIds.add(q.id) + + if (q.claim_sets && !q.claims) { + return `Credential query '${q.id}' has claim_sets but no claims (OID4VP §6.4.1)` + } + } + return undefined +} + +/** + * Validate that all credential query IDs referenced by transaction_data entries + * appear within the options of a single credential set. + */ +export function validateTransactionDataCredentialSet( + dcqlQuery: DcqlQuery, + transactionData: TransactionDataInput[] +): string | undefined { + if (transactionData.length === 0) return undefined + if (!dcqlQuery.credential_sets || dcqlQuery.credential_sets.length === 0) return undefined + + const tdCredentialIds = new Set() + for (const td of transactionData) { + for (const id of td.credential_ids) tdCredentialIds.add(id) + } + + const containingSet = dcqlQuery.credential_sets.find((cs) => + cs.options.some((_opt) => + [...tdCredentialIds].every((id) => { + return cs.options.some((o) => o.includes(id)) + }) + ) + ) + + if (!containingSet) { + return 'All transaction_data credential_ids must appear within options of the same credential set' + } + + return undefined +} + +/** + * Resolve a DCQL query's credential sets into independent slots. + * + * Detects whether the request involves SCA using `config.scaTypeMatcher`: + * + * - **SCA present**: strict TS12 rules — locale MUST satisfy all display arrays, + * SCA options MUST be transposable. Returns `Err` on failure. + * - **No SCA**: best-effort — decomposition never errors, locale is best-effort. + * + * In both modes, slot alternatives only include credentials that successfully + * resolved their associated transaction data. + */ +export function resolveDcql( + dcqlQuery: DcqlQuery, + transactionData: TransactionDataInput[], + matchCredentials: CredentialMatcher, + config: WalletConfiguration +): Result { + const queryValidationError = validateDcqlQueryStructure(dcqlQuery) + if (queryValidationError) return err(queryValidationError) + + // buildCredentialQueryMap is guaranteed to succeed after validation + const queries = buildCredentialQueryMap(dcqlQuery.credentials) as Map + + const credentialSets = dcqlQuery.credential_sets + + // OID4VP §6.4.2: "If credential_sets is not provided, the Verifier requests + // presentations for all Credentials in credentials to be returned." + const effectiveCredentialSets: DcqlCredentialSetQuery[] = + credentialSets && credentialSets.length > 0 + ? credentialSets + : dcqlQuery.credentials.map((q) => ({ options: [[q.id]], required: true })) + + const validationError = validateTransactionDataCredentialSet(dcqlQuery, transactionData) + if (validationError) return err(validationError) + + if (hasScaTransactionData(transactionData, config)) { + return resolveScaDcql(effectiveCredentialSets, queries, transactionData, matchCredentials, config) + } + + return resolveNonScaDcql(effectiveCredentialSets, queries, transactionData, matchCredentials, config) +} diff --git a/packages/dcql/src/result.ts b/packages/dcql/src/result.ts new file mode 100644 index 0000000..66ffa37 --- /dev/null +++ b/packages/dcql/src/result.ts @@ -0,0 +1,18 @@ +/** Rust-style Result type. No exceptions in this package. */ +export type Result = { ok: true; value: T } | { ok: false; error: E } + +export function ok(value: T): Result { + return { ok: true, value } +} + +export function err(error: E): Result { + return { ok: false, error } +} + +export function isOk(result: Result): result is { ok: true; value: T } { + return result.ok +} + +export function isErr(result: Result): result is { ok: false; error: E } { + return !result.ok +} diff --git a/packages/dcql/src/types.ts b/packages/dcql/src/types.ts new file mode 100644 index 0000000..1d26b4c --- /dev/null +++ b/packages/dcql/src/types.ts @@ -0,0 +1,198 @@ +import type { ResolvedTransactionDisplay, ValueTypeResolvers } from '@animo-id/eudi-wallet-ts12-resolver' +import type { ScaCredentialMetadata, ScaTransactionTypeMatcher } from '@animo-id/eudi-wallet-ts12-validation' + +// ============================================================================= +// DCQL Query types per OID4VP 1.0 Section 6 +// ============================================================================= + +/** Claims Path Pointer component per OID4VP Section 7. */ +export type ClaimsPathComponent = string | number | null + +/** A claim query within a credential query (OID4VP Section 6.3). */ +export interface DcqlClaimsQuery { + id?: string + path: ClaimsPathComponent[] + values?: (string | number | boolean)[] + intent_to_retain?: boolean +} + +/** Trusted authority constraint (OID4VP Section 6.1.1). */ +export interface DcqlTrustedAuthoritiesQuery { + type: string + values: string[] +} + +/** Format-specific metadata for dc+sd-jwt (OID4VP Appendix B.3.5). */ +export interface DcqlMetaSdJwt { + vct_values: string[] +} + +/** Format-specific metadata for mso_mdoc (OID4VP Appendix B.2.3). */ +export interface DcqlMetaMsoMdoc { + doctype_value: string +} + +/** Format-specific metadata for jwt_vc_json / ldp_vc (OID4VP Appendix B.1.1). */ +export interface DcqlMetaW3cVc { + type_values: string[][] +} + +export type DcqlMeta = DcqlMetaSdJwt | DcqlMetaMsoMdoc | DcqlMetaW3cVc | Record + +/** A credential query from a DCQL request (OID4VP Section 6.1). */ +export interface DcqlCredentialQuery { + id: string + format: string + meta: DcqlMeta + claims?: DcqlClaimsQuery[] + claim_sets?: string[][] + multiple?: boolean + require_cryptographic_holder_binding?: boolean + trusted_authorities?: DcqlTrustedAuthoritiesQuery[] +} + +/** A credential set from a DCQL request (OID4VP Section 6.2). */ +export interface DcqlCredentialSetQuery { + options: string[][] + description?: string + required?: boolean +} + +/** Top-level DCQL query structure (OID4VP Section 6). */ +export interface DcqlQuery { + credentials: DcqlCredentialQuery[] + credential_sets?: DcqlCredentialSetQuery[] +} + +// ============================================================================= +// Credential display metadata (OID4VCI Section 12.2.4 + TS12 Section 5) +// ============================================================================= + +/** A locale entry in a credential's display array per [OID4VCI] Section 12.2.4. */ +export interface CredentialDisplayEntry { + name: string + locale?: string + description?: string + logo?: { uri: string; alt_text?: string } + background_color?: string + text_color?: string +} + +/** Locale-resolved credential display (single entry, no array). */ +export interface ResolvedCredentialDisplay { + name: string + description?: string + logo?: { uri: string; alt_text?: string } + background_color?: string + text_color?: string +} + +// ============================================================================= +// Wallet configuration +// ============================================================================= + +/** Wallet rendering preferences, threaded through all resolution. */ +export interface WalletConfiguration { + /** Ordered list of user preferred locales (RFC 5646 tags), highest priority first. */ + locales: string[] + /** Value type resolver map. */ + valueTypeResolvers: ValueTypeResolvers + /** Display mode for theming. */ + mode: 'dark' | 'light' + /** + * Predicate that determines whether a transaction_data `type` string + * identifies an SCA-compatible transaction. Defaults to `defaultScaTypeMatcher` + * (matches `urn:eudi:sca:` prefix). Override to support additional standards. + */ + scaTypeMatcher?: ScaTransactionTypeMatcher + /** + * Check whether a credential supports a non-SCA transaction_data type. + * Called for transaction_data entries not matched by `scaTypeMatcher`. + * If absent, all credentials targeted by non-SCA transaction_data are + * considered incompatible. + */ + checkNonScaTransactionDataSupport?: (credentialId: string, transactionDataType: string) => boolean +} + +// ============================================================================= +// Matcher types +// ============================================================================= + +/** A wallet credential matched by a DCQL credential query. */ +export interface MatchedCredential { + credentialId: string + /** SCA credential metadata, if this is an SCA Attestation. */ + scaMetadata?: ScaCredentialMetadata + /** OID4VCI credential display metadata. */ + display?: CredentialDisplayEntry[] +} + +/** + * Resolves a DCQL credential query to matching wallet credentials. + * + * Receives the full query (format, meta, claims, trusted_authorities, etc.) + * and returns matched credentials with their metadata. + */ +export type CredentialMatcher = (query: DcqlCredentialQuery) => MatchedCredential[] + +// ============================================================================= +// Transaction data input +// ============================================================================= + +/** A transaction_data entry from the OID4VP request. */ +export interface TransactionDataInput { + type: string + credential_ids: string[] + payload: Record +} + +// ============================================================================= +// Resolution output +// ============================================================================= + +/** A wallet credential fully resolved with display, claims, and SCA data. */ +export interface ResolvedWalletCredential { + credentialId: string + /** Which DCQL credential query this credential was matched from. */ + credentialQueryId: string + /** Locale-resolved credential display metadata. */ + display?: ResolvedCredentialDisplay + /** Claims requested by the RP for this credential (from the DCQL query). */ + requestedClaims?: DcqlClaimsQuery[] + /** + * Present if a transaction_data entry targets this credential (via credential_ids). + * - SCA entries: `resolved` contains the full locale-resolved display (first-match rule, TS12 Section 3.3 step 3). + * - Non-SCA entries: `resolved` is undefined, `entry` contains the raw transaction data. + */ + transactionData?: { + index: number + entry: TransactionDataInput + resolved?: ResolvedTransactionDisplay + } +} + +/** A slot alternative: one DCQL credential query with its resolved wallet credentials. */ +export interface SlotAlternative { + credentialQueryId: string + credentials: ResolvedWalletCredential[] +} + +/** An independent choice slot within a resolved credential set. */ +export interface ResolvedSlot { + optional: boolean + alternatives: SlotAlternative[] +} + +/** A fully resolved credential set decomposed into independent slots. */ +export interface ResolvedCredentialSet { + description?: string + required: boolean + slots: ResolvedSlot[] +} + +/** Top-level resolution result with the selected locale. */ +export interface ResolvedDcqlResult { + /** The locale selected for the entire presentation (TS12 Section 3.5.4). */ + locale: string + credentialSets: ResolvedCredentialSet[] +} diff --git a/packages/dcql/tsconfig.json b/packages/dcql/tsconfig.json new file mode 100644 index 0000000..f8b80bc --- /dev/null +++ b/packages/dcql/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "noEmitOnError": true, + "lib": ["ES2022"], + "types": [], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["dist"] +} diff --git a/packages/resolver/README.md b/packages/resolver/README.md new file mode 100644 index 0000000..4c42bbe --- /dev/null +++ b/packages/resolver/README.md @@ -0,0 +1,106 @@ +# @animo-id/eudi-wallet-ts12-resolver + +Locale resolution, value type formatting, and transaction display rendering per TS12 Sections 3.3 and 3.5. + +## Install + +```bash +pnpm add @animo-id/eudi-wallet-ts12-resolver +``` + +## Resolve a transaction for display + +Main entry point — takes a transaction data entry and credential metadata, resolves all claims and UI labels for the best matching locale: + +```ts +import { resolveTransactionDisplay } from '@animo-id/eudi-wallet-ts12-resolver' +import type { ValueTypeResolvers } from '@animo-id/eudi-wallet-ts12-resolver' + +const resolvers: ValueTypeResolvers = { + currency_amount: (raw, locale) => new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(Number(raw)), + string: (raw) => raw, +} + +const result = resolveTransactionDisplay( + // Transaction data entry from the OID4VP request + { + type: 'urn:eudi:sca:eu.europa.ec:payment:single:1', + credential_ids: ['payment_credential'], + payload: { + payee_name: 'Coffee Shop', + amount: '4.50', + }, + }, + // Credential metadata (from issuer, contains claims + ui_labels) + { + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [ + { path: ['payee_name'], mandatory: true, display: [{ name: 'Payee', locale: 'en' }] }, + { path: ['amount'], mandatory: true, value_type: 'currency_amount', display: [{ name: 'Amount', locale: 'en' }] }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm payment of {1} to {0}', locale: 'en' }], + denial_action_label: [{ value: 'Cancel', locale: 'en' }], + }, + }, + }, + }, + ['en', 'de'], // locale priority list + resolvers +) + +if (result) { + result.locale // 'en' + result.type // 'urn:eudi:sca:eu.europa.ec:payment:single:1' + result.claims // [{ path: ['payee_name'], label: { value: 'Payee' }, value: { value: 'Coffee Shop' } }, ...] + result.ui_labels // { affirmative_action_label: { value: 'Confirm payment of €4.50 to Coffee Shop' }, ... } +} +``` + +Returns `undefined` if the transaction type is not found in the metadata, mandatory claims are missing, or no locale can fully resolve all display arrays. + +## Locale selection + +RFC 4647 Basic Lookup matching with fallback to the default (no-locale) entry: + +```ts +import { selectLocaleEntry } from '@animo-id/eudi-wallet-ts12-resolver' + +const entries = [ + { name: 'Zahlung', locale: 'de' }, + { name: 'Payment', locale: 'en' }, + { name: 'Payment (default)' }, // no locale = default fallback +] + +selectLocaleEntry(entries, 'en') // { name: 'Payment', locale: 'en' } +selectLocaleEntry(entries, 'fr') // { name: 'Payment (default)' } +selectLocaleEntry(entries, 'de-AT') // { name: 'Zahlung', locale: 'de' } — subtag truncation +``` + +## Value type resolvers + +Pluggable map of `value_type` identifiers to formatting functions. Each resolver receives the raw value and locale: + +```ts +import type { ValueTypeResolvers } from '@animo-id/eudi-wallet-ts12-resolver' + +const resolvers: ValueTypeResolvers = { + currency_amount: (raw, locale) => `${raw} EUR`, + date: (raw, locale) => new Date(raw).toLocaleDateString(locale), + string: (raw) => raw, +} +``` + +Claims with an unrecognized `value_type` are skipped. Claims without a `value_type` pass through the raw value. + +## Lower-level API + +```ts +import { + resolveAllClaims, // resolve all displayable claims with wildcard expansion + resolveAllUiLabels, // resolve all UI labels with placeholder interpolation + validateMandatoryClaims, // check mandatory claims are present in payload + getPayloadValue, // walk a Claims Path Pointer through nested data +} from '@animo-id/eudi-wallet-ts12-resolver' +``` diff --git a/packages/resolver/package.json b/packages/resolver/package.json new file mode 100644 index 0000000..28dd15c --- /dev/null +++ b/packages/resolver/package.json @@ -0,0 +1,41 @@ +{ + "name": "@animo-id/eudi-wallet-ts12-resolver", + "description": "EUDI Wallet TS12 locale resolution and value type resolution", + "version": "0.1.0", + "license": "Apache-2.0", + "author": "Frederic Artus Nieto for DSGV", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/animo/eudi-wallet-functionality", + "directory": "packages/resolver" + }, + "scripts": { + "types:check": "tsc --noEmit", + "build": "tsdown src/index.ts --format esm --dts --clean --sourcemap" + }, + "dependencies": { + "@animo-id/eudi-wallet-ts12-validation": "workspace:*", + "bcp-47-match": "^2.0.3" + }, + "devDependencies": { + "tsdown": "^0.18.4", + "typescript": "~5.9.3" + } +} diff --git a/packages/resolver/src/index.ts b/packages/resolver/src/index.ts new file mode 100644 index 0000000..832b785 --- /dev/null +++ b/packages/resolver/src/index.ts @@ -0,0 +1,6 @@ +export * from './locale-lookup' +export * from './resolve-claims' +export * from './resolve-transaction' +export * from './resolve-ui-labels' +export * from './resolve-value' +export * from './types' diff --git a/packages/resolver/src/locale-lookup.ts b/packages/resolver/src/locale-lookup.ts new file mode 100644 index 0000000..260a148 --- /dev/null +++ b/packages/resolver/src/locale-lookup.ts @@ -0,0 +1,31 @@ +import { lookup } from 'bcp-47-match' + +/** RFC 4647 Section 3.4 — Basic Lookup. Wraps bcp-47-match. */ +export function lookupLocale(range: string, availableTags: string[]): string | undefined { + return lookup(availableTags, [range]) || undefined +} + +/** + * TS12 Section 3.5.4 — Select a single entry from a locale-tagged array. + * + * 1. Apply RFC 4647 Lookup using `locale` as the range. + * 2. If multiple entries match at the same truncation step, the first in + * array order wins. + * 3. If no tag matches, fall back to the first entry that omits `locale` + * (the default entry per TS12 3.5.4). + * + * Returns undefined when no entry matches and no default exists. + */ +export function selectLocaleEntry(entries: T[], locale: string): T | undefined { + const tagged = entries.filter((e): e is T & { locale: string } => e.locale !== undefined) + const matched = lookup( + tagged.map((e) => e.locale), + [locale] + ) + + if (matched) { + return tagged.find((e) => e.locale.toLowerCase() === matched.toLowerCase()) + } + + return entries.find((e) => e.locale === undefined) +} diff --git a/packages/resolver/src/resolve-claims.ts b/packages/resolver/src/resolve-claims.ts new file mode 100644 index 0000000..0298ff1 --- /dev/null +++ b/packages/resolver/src/resolve-claims.ts @@ -0,0 +1,311 @@ +import type { ClaimMetadata, ClaimsPathComponent } from '@animo-id/eudi-wallet-ts12-validation' +import { selectLocaleEntry } from './locale-lookup' +import { getPayloadValue, resolveTypedValue } from './resolve-value' +import type { ResolvedClaim, ValueTypeResolvers } from './types' + +/** Narrow to the displayable variant of the ClaimMetadata union. */ +function isDisplayable(claim: ClaimMetadata): claim is ClaimMetadata & { display: NonNullable } { + return 'display' in claim && Array.isArray((claim as Record).display) +} + +/** + * TS12 Section 3.5.2 — Filter out display entries whose `display_type` is not + * supported by the Wallet Unit (i.e. not present in the resolvers map). + * + * Entries without `display_type` (plain text) are always kept. + */ +export function filterSupportedDisplayEntries( + entries: T[], + resolvers: ValueTypeResolvers +): T[] { + return entries.filter((e) => !e.display_type || e.display_type in resolvers) +} + +/** + * TS12 Section 3.3 step 3 — Check that every mandatory claim is present in the payload. + * Applies to all claims (displayable and internal). + */ +export function validateMandatoryClaims(claims: ClaimMetadata[], payload: Record): boolean { + for (const claim of claims) { + if (claim.mandatory && getPayloadValue(payload, claim.path) === undefined) { + return false + } + } + return true +} + +// ============================================================================= +// Undeclared payload field validation +// ============================================================================= + +interface DeclaredTree { + [key: string]: true | DeclaredTree +} + +function buildDeclaredTree(claims: ClaimMetadata[]): DeclaredTree { + const root: DeclaredTree = {} + + for (const claim of claims) { + let node = root + for (let i = 0; i < claim.path.length; i++) { + const segment = claim.path[i] + if (typeof segment !== 'string') break + + const isLast = i === claim.path.length - 1 || typeof claim.path[i + 1] !== 'string' + const existing = node[segment] + + if (existing === true) break + + if (isLast) { + node[segment] = true + } else { + if (!existing) { + node[segment] = {} + } + node = node[segment] as DeclaredTree + } + } + } + + return root +} + +function validateObject(obj: Record, tree: DeclaredTree): boolean { + for (const key of Object.keys(obj)) { + const declaration = tree[key] + if (declaration === undefined) return false + + if (declaration === true) continue + + const value = obj[key] + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + if (!validateObject(value as Record, declaration)) return false + } + } + return true +} + +/** + * TS12 Section 3.3 step 3 — Check that the payload does not contain fields + * not declared in the claims metadata. + */ +export function validateNoUndeclaredPayloadFields(claims: ClaimMetadata[], payload: Record): boolean { + const tree = buildDeclaredTree(claims) + return validateObject(payload, tree) +} + +// ============================================================================= +// Wildcard (null) claim expansion — recursive multi-depth +// ============================================================================= + +/** Split a path at the first null: prefix (before), suffix (after). */ +function splitAtFirstNull( + path: ClaimsPathComponent[] +): { prefix: ClaimsPathComponent[]; suffix: ClaimsPathComponent[] } | undefined { + const idx = path.indexOf(null) + if (idx === -1) return undefined + return { prefix: path.slice(0, idx), suffix: path.slice(idx + 1) } +} + +/** Canonical key for grouping: path segments before the first null. */ +function groupKey(path: ClaimsPathComponent[]): string { + const idx = path.indexOf(null) + const prefix = idx === -1 ? path : path.slice(0, idx) + return prefix.map((p) => String(p)).join('\0') +} + +/** Check if a path contains at least one null (wildcard). */ +function hasWildcard(path: ClaimsPathComponent[]): boolean { + return path.includes(null) +} + +interface ExpandedClaim { + claim: ClaimMetadata + concretePath: ClaimsPathComponent[] +} + +/** + * Recursively expand wildcard claims, grouping at each wildcard level. + * + * Claims sharing the same prefix before their first `null` are grouped. + * For each array index, the group's members are emitted together. + * If a member's suffix still contains `null`, it is recursively expanded. + * + * Example with two levels: + * ``` + * ["orders", null, "items", null, "name"] + * ["orders", null, "items", null, "price"] + * ["orders", null, "date"] + * ``` + * With `orders: [{ date: "D1", items: [{name:"A",price:"1"},{name:"B",price:"2"}] }, + * { date: "D2", items: [{name:"C",price:"3"}] }]` + * + * Expands to (outer first, inner grouped closest to leaf): + * ``` + * ["orders", 0, "date"] + * ["orders", 0, "items", 0, "name"] + * ["orders", 0, "items", 0, "price"] + * ["orders", 0, "items", 1, "name"] + * ["orders", 0, "items", 1, "price"] + * ["orders", 1, "date"] + * ["orders", 1, "items", 0, "name"] + * ["orders", 1, "items", 0, "price"] + * ``` + */ +export function expandClaims( + claims: ClaimMetadata[], + payload: Record, + pathPrefix: ClaimsPathComponent[] = [] +): ExpandedClaim[] { + // Separate wildcard and non-wildcard claims + const wildcardClaims: Array<{ claim: ClaimMetadata; prefix: ClaimsPathComponent[]; suffix: ClaimsPathComponent[] }> = + [] + const plainClaims: ClaimMetadata[] = [] + + for (const claim of claims) { + const split = splitAtFirstNull(claim.path) + if (split) { + wildcardClaims.push({ claim, prefix: split.prefix, suffix: split.suffix }) + } else { + plainClaims.push(claim) + } + } + + // Group wildcard claims by their prefix before the first null + type WildcardGroup = { + prefix: ClaimsPathComponent[] + members: Array<{ claim: ClaimMetadata; suffix: ClaimsPathComponent[] }> + } + const groups = new Map() + for (const wc of wildcardClaims) { + const key = wc.prefix.map((p) => String(p)).join('\0') + let group = groups.get(key) + if (!group) { + group = { prefix: wc.prefix, members: [] } + groups.set(key, group) + } + group.members.push({ claim: wc.claim, suffix: wc.suffix }) + } + + // Emit in original claims array order + const result: ExpandedClaim[] = [] + const emittedGroups = new Set() + + for (const claim of claims) { + if (!hasWildcard(claim.path)) { + result.push({ claim, concretePath: [...pathPrefix, ...claim.path] }) + continue + } + + const key = groupKey(claim.path) + if (emittedGroups.has(key)) continue + emittedGroups.add(key) + + const group = groups.get(key) as WildcardGroup + const arrayPath = [...pathPrefix, ...group.prefix] + const arrayValue = getPayloadValue(payload as Record, arrayPath) + const arrayLen = Array.isArray(arrayValue) ? arrayValue.length : 0 + + for (let idx = 0; idx < arrayLen; idx++) { + const indexPrefix = [...arrayPath, idx] + + // Check if any members still have wildcards in their suffix (multi-depth) + const hasDeeper = group.members.some((m) => hasWildcard(m.suffix)) + + if (hasDeeper) { + // Recursively expand: create virtual claims with the suffix as their path + const subClaims: ClaimMetadata[] = group.members.map((m) => ({ + ...m.claim, + path: m.suffix, + })) + const subExpanded = expandClaims(subClaims, payload, indexPrefix) + result.push(...subExpanded) + } else { + // Leaf level: emit each member with concrete path + for (const member of group.members) { + result.push({ + claim: member.claim, + concretePath: [...indexPrefix, ...member.suffix], + }) + } + } + } + } + + return result +} + +// ============================================================================= +// Single claim resolution +// ============================================================================= + +/** + * Resolve a single displayable claim at a specific path. + */ +export function resolveDisplayableClaim( + claim: ClaimMetadata & { display: Array<{ name: string; locale?: string; display_type?: string }> }, + payload: Record, + locale: string, + resolvers: ValueTypeResolvers, + pathOverride?: ClaimsPathComponent[] +): ResolvedClaim | undefined { + const supported = filterSupportedDisplayEntries(claim.display, resolvers) + const entry = selectLocaleEntry(supported, locale) + if (!entry) return undefined + + const label = resolveTypedValue(entry.name, entry.display_type, resolvers, locale) + if (!label) return undefined + + const resolvePath = pathOverride ?? claim.path + const rawValue = getPayloadValue(payload, resolvePath) + const value = resolveTypedValue(rawValue, (claim as { value_type?: string }).value_type, resolvers, locale) + if (!value) return undefined + + return { path: resolvePath, mandatory: claim.mandatory, label, value } +} + +// ============================================================================= +// Full claims resolution with wildcard expansion +// ============================================================================= + +/** + * Resolve all displayable claims in array order, expanding wildcard groups. + * + * Wildcard (`null`) claims sharing the same prefix are grouped and expanded + * per array index. Multi-depth wildcards are recursively expanded, with + * inner wildcards grouped closest to leaf first. + * + * - Mandatory claims missing from the payload → `undefined`. + * - Optional displayable claims absent from the payload are skipped. + * - Preserves the claim order from the `claims` array (TS12 Section 3.5.1). + */ +export function resolveAllClaims( + claims: ClaimMetadata[], + payload: Record, + locale: string, + resolvers: ValueTypeResolvers +): ResolvedClaim[] | undefined { + if (!validateMandatoryClaims(claims, payload)) return undefined + if (!validateNoUndeclaredPayloadFields(claims, payload)) return undefined + + const expanded = expandClaims(claims, payload) + const resolved: ResolvedClaim[] = [] + + for (const { claim, concretePath } of expanded) { + if (!isDisplayable(claim)) continue + + const rawValue = getPayloadValue(payload, concretePath) + if (rawValue === undefined) continue + + const displayClaim = claim as ClaimMetadata & { + display: Array<{ name: string; locale?: string; display_type?: string }> + } + + const result = resolveDisplayableClaim(displayClaim, payload, locale, resolvers, concretePath) + if (!result) return undefined + + resolved.push(result) + } + + return resolved +} diff --git a/packages/resolver/src/resolve-transaction.ts b/packages/resolver/src/resolve-transaction.ts new file mode 100644 index 0000000..33717a7 --- /dev/null +++ b/packages/resolver/src/resolve-transaction.ts @@ -0,0 +1,115 @@ +import type { + ClaimMetadata, + ScaCredentialMetadata, + ScaTransactionDataEntry, + TransactionDataType, +} from '@animo-id/eudi-wallet-ts12-validation' +import { selectLocaleEntry } from './locale-lookup' +import { + filterSupportedDisplayEntries, + resolveAllClaims, + validateMandatoryClaims, + validateNoUndeclaredPayloadFields, +} from './resolve-claims' +import { resolveAllUiLabels } from './resolve-ui-labels' +import type { ResolvedTransactionDisplay, ValueTypeResolvers } from './types' + +/** + * TS12 Section 3.5.4 — Verify that every `display` array across all claims + * and UI label entries can produce a match for the given locale. + * + * Display entries with an unsupported `display_type` are excluded from matching + * per Section 3.5.2. + */ +export function verifyLocaleSupport( + typeMetadata: TransactionDataType, + locale: string, + resolvers: ValueTypeResolvers +): boolean { + for (const claim of typeMetadata.claims) { + if ('display' in claim && Array.isArray((claim as Record).display)) { + const display = (claim as { display: Array<{ locale?: string; display_type?: string }> }).display + const supported = filterSupportedDisplayEntries(display, resolvers) + if (!selectLocaleEntry(supported, locale)) return false + } + } + + for (const entries of Object.values(typeMetadata.ui_labels)) { + if (Array.isArray(entries)) { + if (!selectLocaleEntry(entries as Array<{ locale?: string }>, locale)) return false + } + } + + return true +} + +/** + * Attempt full resolution for a single locale. + * + * Returns `undefined` if locale matching, claim resolution, + * or UI label interpolation fails for this locale. + */ +function tryResolveForLocale( + typeMetadata: TransactionDataType, + typeKey: string, + payload: Record, + locale: string, + resolvers: ValueTypeResolvers +): ResolvedTransactionDisplay | undefined { + if (!verifyLocaleSupport(typeMetadata, locale, resolvers)) return undefined + + const claims = resolveAllClaims(typeMetadata.claims as ClaimMetadata[], payload, locale, resolvers) + if (!claims) return undefined + + const uiLabels = resolveAllUiLabels( + typeMetadata.ui_labels as Record>, + locale, + typeMetadata.claims as ClaimMetadata[], + payload, + resolvers + ) + if (!uiLabels) return undefined + + return { locale, type: typeKey, claims, ui_labels: uiLabels } +} + +/** + * TS12 Sections 3.3, 3.5.1–3.5.4 — Resolve the transaction display. + * + * Accepts a single locale or an ordered priority list per Section 3.5.4. + * Tries each locale in order: + * 1. Match the transaction type to a key in `transaction_data_types`. + * 2. Check mandatory claims are present (locale-independent). + * 3. For each locale: verify all display arrays match, resolve claims + UI labels. + * 4. Return the first locale that fully resolves, with the selected locale in the result. + * + * Returns `undefined` if the type is not found, mandatory claims are missing, + * or no locale in the list produces a complete resolution. + */ +export function resolveTransactionDisplay( + transactionData: ScaTransactionDataEntry, + credentialMetadata: ScaCredentialMetadata, + locales: string | string[], + resolvers: ValueTypeResolvers +): ResolvedTransactionDisplay | undefined { + const typeKey = transactionData.type + const typeMetadata = credentialMetadata.transaction_data_types[typeKey] + if (!typeMetadata) return undefined + + // Locale-independent checks (Section 3.3 step 3) + if (!validateMandatoryClaims(typeMetadata.claims as ClaimMetadata[], transactionData.payload)) { + return undefined + } + if (!validateNoUndeclaredPayloadFields(typeMetadata.claims as ClaimMetadata[], transactionData.payload)) { + return undefined + } + + const localeList = Array.isArray(locales) ? locales : [locales] + + for (const locale of localeList) { + const result = tryResolveForLocale(typeMetadata, typeKey, transactionData.payload, locale, resolvers) + if (result) return result + } + + return undefined +} diff --git a/packages/resolver/src/resolve-ui-labels.ts b/packages/resolver/src/resolve-ui-labels.ts new file mode 100644 index 0000000..1ffa933 --- /dev/null +++ b/packages/resolver/src/resolve-ui-labels.ts @@ -0,0 +1,131 @@ +import type { ClaimMetadata, UiLabelEntry } from '@animo-id/eudi-wallet-ts12-validation' +import { selectLocaleEntry } from './locale-lookup' +import { getPayloadValue, resolveTypedValue } from './resolve-value' +import type { ResolvedValue, ValueTypeResolvers } from './types' + +/** + * TS12 Section 3.5.3 — Placeholder interpolation. + * + * Replaces `{}` placeholders with formatted claim values: + * 1. Look up the claim at the zero-based index in the `claims` array. + * Out-of-bounds indices are kept as literal text. + * 2. Resolve the claim's value from the payload using its `path`. + * If absent, the entire locale entry must be discarded → returns `undefined`. + * 3. Format the value according to the claim's `value_type`. + * 4. Replace the placeholder with the formatted string. + */ +export function interpolatePlaceholders( + template: string, + claims: ClaimMetadata[], + payload: Record, + resolvers: ValueTypeResolvers, + locale: string +): string | undefined { + let discarded = false + + const result = template.replace(/\{(\d+)\}/g, (_match, indexStr: string) => { + const index = Number.parseInt(indexStr, 10) + + if (index >= claims.length) return _match // out of bounds → literal + + const claim = claims[index] + const rawValue = getPayloadValue(payload, claim.path) + if (rawValue === undefined) { + discarded = true + return '' + } + + const valueType = 'value_type' in claim ? (claim as { value_type?: string }).value_type : undefined + const resolved = resolveTypedValue(rawValue, valueType, resolvers, locale) + if (!resolved) { + discarded = true + return '' + } + + return String(resolved.value) + }) + + return discarded ? undefined : result +} + +/** + * Resolve a single UI label for a given locale. + * + * Selects the best matching locale entry, interpolates placeholders, + * then applies the entry's own `value_type` to the resulting string. + * + * If the selected entry is discarded (placeholder references a missing claim), + * falls back to the default entry (no locale) which always matches any locale. + * If that also fails → `undefined` (try next locale in priority list). + */ +export function resolveUiLabel( + entries: UiLabelEntry[], + locale: string, + claims: ClaimMetadata[], + payload: Record, + resolvers: ValueTypeResolvers +): ResolvedValue | undefined { + const selected = selectLocaleEntry(entries, locale) + if (!selected) return undefined + + const result = tryResolveEntry(selected, claims, payload, resolvers, locale) + if (result) return result + + // Selected entry was discarded — try the default entry (no locale, always valid) + const defaultEntry = entries.find((e) => e.locale === undefined) + if (defaultEntry && defaultEntry !== selected) { + return tryResolveEntry(defaultEntry, claims, payload, resolvers, locale) + } + + return undefined +} + +/** + * Attempt to resolve a single UI label entry: interpolate placeholders, + * then apply the entry's `value_type` to the whole string. + */ +function tryResolveEntry( + entry: UiLabelEntry, + claims: ClaimMetadata[], + payload: Record, + resolvers: ValueTypeResolvers, + locale: string +): ResolvedValue | undefined { + const interpolated = interpolatePlaceholders(entry.value, claims, payload, resolvers, locale) + if (interpolated === undefined) return undefined + + return resolveTypedValue(interpolated, entry.value_type, resolvers, locale) +} + +/** + * Resolve all UI labels in the catalogue. + * + * The `affirmative_action_label` is required — if it fails, returns `undefined`. + * Optional labels that fail are omitted from the output. + */ +export function resolveAllUiLabels( + uiLabels: Record, + locale: string, + claims: ClaimMetadata[], + payload: Record, + resolvers: ValueTypeResolvers +): Record | undefined { + const resolved: Record = {} + + for (const [key, entries] of Object.entries(uiLabels)) { + if (!Array.isArray(entries)) continue + + const result = resolveUiLabel(entries, locale, claims, payload, resolvers) + + if (key === 'affirmative_action_label' && !result) return undefined + + if (result) { + resolved[key] = result + } + } + + // affirmative_action_label must be present + if (!resolved.affirmative_action_label) return undefined + + return resolved +} diff --git a/packages/resolver/src/resolve-value.ts b/packages/resolver/src/resolve-value.ts new file mode 100644 index 0000000..58c6f62 --- /dev/null +++ b/packages/resolver/src/resolve-value.ts @@ -0,0 +1,73 @@ +import type { ClaimsPathComponent } from '@animo-id/eudi-wallet-ts12-validation' +import type { ResolvedValue, ValueTypeResolvers } from './types' + +/** + * [OID4VCI] Appendix B — Walk a Claims Path Pointer through a nested structure. + * + * Path component semantics: + * - `string`: select a key in an object + * - `number` (non-negative integer): select an array index + * - `null`: select ALL elements of an array (wildcard) + * + * When `null` is encountered on an array, the remaining path is applied + * to each element and results are collected into an array. + * + * A negative integer aborts and returns `undefined` per OID4VCI processing rules. + */ +export function getPayloadValue(payload: Record, path: ClaimsPathComponent[]): unknown | undefined { + return walkPath(payload, path, 0) +} + +function walkPath(current: unknown, path: ClaimsPathComponent[], index: number): unknown | undefined { + if (index >= path.length) return current + if (current === null || current === undefined) return undefined + + const key = path[index] + + // null = wildcard: map over all array elements + if (key === null) { + if (!Array.isArray(current)) return undefined + const results = current.map((item) => walkPath(item, path, index + 1)) + return results.every((r) => r === undefined) ? undefined : results + } + + // number = array index (negative integers abort per OID4VCI) + if (typeof key === 'number') { + if (!Array.isArray(current)) return undefined + if (key < 0 || key >= current.length) return undefined + return walkPath(current[key], path, index + 1) + } + + // string = object key + if (typeof current === 'object' && !Array.isArray(current)) { + return walkPath((current as Record)[key], path, index + 1) + } + + return undefined +} + +/** + * Resolve a raw value through the appropriate value type resolver. + * + * - No `valueType`: the value passes through as plain text → `{ type: undefined, value: rawValue }`. + * - `valueType` present but no resolver registered: unsupported → `undefined`. + * - Resolver returns `undefined`: invalid value → `undefined`. + */ +export function resolveTypedValue( + rawValue: unknown, + valueType: string | undefined, + resolvers: ValueTypeResolvers, + locale: string +): ResolvedValue | undefined { + if (!valueType) { + return { type: undefined, value: rawValue } + } + + const resolver = resolvers[valueType] + if (!resolver) return undefined + + const resolved = resolver(rawValue as string, locale) + if (resolved === undefined) return undefined + + return { type: valueType, value: resolved } +} diff --git a/packages/resolver/src/types.ts b/packages/resolver/src/types.ts new file mode 100644 index 0000000..c02bb9d --- /dev/null +++ b/packages/resolver/src/types.ts @@ -0,0 +1,24 @@ +/** Map of value_type identifiers to resolver functions. */ +export type ValueTypeResolvers = Record unknown | undefined> + +/** A display value resolved to a single typed result. */ +export interface ResolvedValue { + type?: string + value: unknown +} + +/** A displayable claim resolved to a single locale with label and payload value. */ +export interface ResolvedClaim { + path: (string | number | null)[] + mandatory?: boolean + label: ResolvedValue + value: ResolvedValue +} + +/** Fully resolved transaction display output. */ +export interface ResolvedTransactionDisplay { + locale: string + type: string + claims: ResolvedClaim[] + ui_labels: Record +} diff --git a/packages/resolver/tsconfig.json b/packages/resolver/tsconfig.json new file mode 100644 index 0000000..f8b80bc --- /dev/null +++ b/packages/resolver/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "noEmitOnError": true, + "lib": ["ES2022"], + "types": [], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["dist"] +} diff --git a/packages/validation/README.md b/packages/validation/README.md new file mode 100644 index 0000000..19c3fda --- /dev/null +++ b/packages/validation/README.md @@ -0,0 +1,139 @@ +# @animo-id/eudi-wallet-ts12-validation + +Zod schemas for EUDI Wallet TS12 data structures. + +## Install + +```bash +pnpm add @animo-id/eudi-wallet-ts12-validation +``` + +## Schemas + +### SCA credential metadata (Section 4.1) + +The `transaction_data_types` object describes what transaction data a credential supports. Keys are URNs starting with `urn:eudi:sca:`, values describe claims and UI labels. + +```ts +import { zScaCredentialMetadata } from '@animo-id/eudi-wallet-ts12-validation' + +const metadata = zScaCredentialMetadata.parse({ + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [ + { path: ['payee_name'], mandatory: true, display: [{ name: 'Payee' }] }, + { path: ['amount'], mandatory: true, value_type: 'currency_amount', display: [{ name: 'Amount' }] }, + { path: ['internal_ref'] }, // no display → internal claim + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm payment' }], + denial_action_label: [{ value: 'Cancel' }], + transaction_title: [{ value: 'Payment of {1} to {0}' }], + }, + }, + }, + // additional OID4VCI fields (display, etc.) are allowed +}) +``` + +### Credential metadata JWT header & payload (Section 5) + +The signed JWT wrapping credential metadata, served at `credential_metadata_uri`. + +```ts +import { + zCredentialMetadataJwtHeader, + zCredentialMetadataJwtPayload, +} from '@animo-id/eudi-wallet-ts12-validation' + +const header = zCredentialMetadataJwtHeader.parse({ + typ: 'credential-metadata+jwt', + alg: 'ES256', + x5c: ['MIIBxTCCAWugAwIBAgent...', '...root-ca-base64...'], +}) + +const payload = zCredentialMetadataJwtPayload.parse({ + iss: 'https://issuer.example.com', + sub: 'https://pay.example.com/card', + format: 'dc+sd-jwt', + iat: 1711929600, + exp: 1712016000, + credential_metadata_uri: 'https://issuer.example.com/credential-metadata/card', + credential_metadata: { + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [{ path: ['payee_name'], display: [{ name: 'Payee' }] }], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm' }], + }, + }, + }, + }, +}) +``` + +### SCA transaction data entry (Section 4.2) + +A transaction data entry from an OpenID4VP authorization request, with an SCA `payload`. + +```ts +import { zTransactionDataEntry, isSCATransaction } from '@animo-id/eudi-wallet-ts12-validation' + +const entry = zTransactionDataEntry.parse({ + type: 'urn:eudi:sca:eu.europa.ec:payment:single:1', + credential_ids: ['card_credential'], + payload: { + payee_name: 'Coffee Shop', + amount: '4.50', + currency: 'EUR', + }, +}) + +if (isSCATransaction(entry)) { + // entry.type starts with 'urn:eudi:sca:' + // entry.payload is the transaction details object +} +``` + +### Funke QES transaction data + +German EUDI Wallet profile for qualified electronic signatures. + +```ts +const qesEntry = zTransactionDataEntry.parse({ + type: 'sign_document', + credential_ids: ['qes_credential'], + signatureQualifier: 'eu_eidas_qes', + documentDigests: [ + { label: 'Contract.pdf', hash: 'dGVzdGhhc2g=', hashAlgorithmOID: '2.16.840.1.101.3.4.2.1' }, + ], +}) +``` + +### SCA type matching + +SCA type detection is fully configurable — different standards can use different URN prefixes. + +```ts +import { + defaultScaTypeMatcher, + createScaTypeMatcher, + isScaAttestationMetadata, +} from '@animo-id/eudi-wallet-ts12-validation' + +// Default matcher: matches 'urn:eudi:sca:' prefix +defaultScaTypeMatcher('urn:eudi:sca:eu.europa.ec:payment:single:1') // true +defaultScaTypeMatcher('sign_document') // false + +// Custom matcher for additional standards +const matcher = createScaTypeMatcher('urn:eudi:sca:', 'urn:other:sca:') +matcher('urn:eudi:sca:eu.europa.ec:payment:single:1') // true +matcher('urn:other:sca:com.example:transfer:1') // true + +// isScaAttestationMetadata accepts an optional matcher (defaults to urn:eudi:sca:) +isScaAttestationMetadata({ transaction_data_types: { 'urn:eudi:sca:eu.europa.ec:payment:single:1': {} } }) // true +isScaAttestationMetadata( + { transaction_data_types: { 'urn:other:sca:com.example:transfer:1': {} } }, + matcher +) // true +``` diff --git a/packages/validation/package.json b/packages/validation/package.json new file mode 100644 index 0000000..1704b11 --- /dev/null +++ b/packages/validation/package.json @@ -0,0 +1,40 @@ +{ + "name": "@animo-id/eudi-wallet-ts12-validation", + "description": "EUDI Wallet TS12 transaction data and metadata validation schemas", + "version": "0.1.0", + "license": "Apache-2.0", + "author": "Frederic Artus Nieto for DSGV", + "exports": "./src/index.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=22" + }, + "publishConfig": { + "access": "public", + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/animo/eudi-wallet-functionality", + "directory": "packages/validation" + }, + "scripts": { + "types:check": "tsc --noEmit", + "build": "tsdown src/index.ts --format esm --dts --clean --sourcemap" + }, + "dependencies": { + "zod": "^4.3.5" + }, + "devDependencies": { + "tsdown": "^0.18.4", + "typescript": "~5.9.3" + } +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts new file mode 100644 index 0000000..084e7d4 --- /dev/null +++ b/packages/validation/src/index.ts @@ -0,0 +1,7 @@ +export * from './is-sca-transaction' +export * from './z-credential-metadata-jwt' +export * from './z-sca-attestation-ext' +export * from './z-transaction-data' +export * from './z-transaction-data-common' +export * from './z-transaction-data-funke' +export * from './z-transaction-data-ts12' diff --git a/packages/validation/src/is-sca-transaction.ts b/packages/validation/src/is-sca-transaction.ts new file mode 100644 index 0000000..3279571 --- /dev/null +++ b/packages/validation/src/is-sca-transaction.ts @@ -0,0 +1,46 @@ +/** + * A predicate that determines whether a transaction data type string + * identifies an SCA-compatible transaction. + * + * Different standards use different URN prefixes (e.g., `urn:eudi:sca:`, + * `urn:paso:sca:`). The predicate is fully configurable — callers decide + * what constitutes an SCA type. + */ +export type ScaTransactionTypeMatcher = (type: string) => boolean + +/** + * Create an SCA type matcher that matches one or more URN prefixes. + * + * @example + * ```ts + * const matcher = createScaTypeMatcher('urn:eudi:sca:') + * + * // Multiple prefixes + * const multi = createScaTypeMatcher('urn:eudi:sca:', 'urn:other:sca:') + * ``` + */ +export function createScaTypeMatcher(...prefixes: [string, ...string[]]): ScaTransactionTypeMatcher { + return (type: string) => prefixes.some((prefix) => type.startsWith(prefix)) +} + +/** + * Default SCA type matcher — matches the `urn:eudi:sca:` prefix per TS12. + * + * Use `createScaTypeMatcher` to build matchers for additional prefixes. + */ +export const defaultScaTypeMatcher: ScaTransactionTypeMatcher = createScaTypeMatcher('urn:eudi:sca:') + +/** + * Checks whether credential metadata describes an SCA Attestation + * by verifying that `transaction_data_types` contains at least one key + * recognized by the given matcher. + * + * @param matcher - Predicate to check type strings. Defaults to `defaultScaTypeMatcher`. + */ +export function isScaAttestationMetadata( + metadata: { transaction_data_types?: Record }, + matcher: ScaTransactionTypeMatcher = defaultScaTypeMatcher +): boolean { + if (!metadata.transaction_data_types) return false + return Object.keys(metadata.transaction_data_types).some(matcher) +} diff --git a/packages/validation/src/z-credential-metadata-jwt.ts b/packages/validation/src/z-credential-metadata-jwt.ts new file mode 100644 index 0000000..00fb7a7 --- /dev/null +++ b/packages/validation/src/z-credential-metadata-jwt.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { zCredentialMetadata } from './z-sca-attestation-ext' + +/** + * TS12 Section 5 — JOSE header for a credential-metadata JWT. + * + * Uses `.loose()` to allow additional JOSE header parameters per RFC 7515. + */ +export const zCredentialMetadataJwtHeader = z + .object({ + /** REQUIRED. MUST be 'credential-metadata+jwt'. */ + typ: z.literal('credential-metadata+jwt'), + /** REQUIRED. X.509 certificate chain per RFC 7515 Section 4.1.6. */ + x5c: z.array(z.string()).nonempty(), + /** REQUIRED. Signature algorithm identifier. */ + alg: z.string(), + }) + .loose() +export type CredentialMetadataJwtHeader = z.infer + +/** + * TS12 Section 5 — Credential metadata JWT payload. + * + * Uses `.loose()` to allow additional claims. + */ +export const zCredentialMetadataJwtPayload = z + .object({ + /** REQUIRED. Credential Issuer Identifier. */ + iss: z.string(), + /** REQUIRED. Credential type identifier (vct for SD-JWT-VC, doctype for mdoc). */ + sub: z.string(), + /** REQUIRED. Credential format identifier (e.g., 'dc+sd-jwt', 'mso_mdoc'). */ + format: z.string(), + /** REQUIRED. Issued-at timestamp (NumericDate per RFC 7519). */ + iat: z.number(), + /** REQUIRED. Expiration timestamp (NumericDate per RFC 7519). */ + exp: z.number(), + /** REQUIRED. The URL from which this JWT was served and for re-fetch on renewal. */ + credential_metadata_uri: z.string(), + /** REQUIRED. The credential metadata object per [OID4VCI] Section 12.2.4, extended with `transaction_data_types` per TS12 Section 4.1. */ + credential_metadata: zCredentialMetadata, + }) + .loose() +export type CredentialMetadataJwtPayload = z.infer diff --git a/packages/validation/src/z-sca-attestation-ext.ts b/packages/validation/src/z-sca-attestation-ext.ts new file mode 100644 index 0000000..5e0aa90 --- /dev/null +++ b/packages/validation/src/z-sca-attestation-ext.ts @@ -0,0 +1,179 @@ +import { z } from 'zod' + +/** + * TS12 Section 3.5.2 — Claim metadata display entry. + * + * Per Section 3.5.4, entries that omit `locale` are default entries. + * Uses `name` per [OID4VCI] Appendix B.2. + */ +export const zClaimDisplayEntry = z.object({ + name: z.string(), + locale: z.string().optional(), + /** How the Wallet Unit SHALL format the `name` text for display. */ + display_type: z.string().optional(), +}) +export type ClaimDisplayEntry = z.infer + +/** + * [OID4VCI] Appendix B — Claims Path Pointer component. + * "A claims path pointer MUST be a non-empty array of strings, nulls and integers." + */ +export const zClaimsPathComponent = z.union([z.string(), z.number().int(), z.null()]) +export type ClaimsPathComponent = z.infer + +/** Fields shared by all claim metadata variants. */ +const zClaimBase = { + /** Claims Path Pointer per [OID4VCI] Appendix B, resolves against `transaction_data.payload`. */ + path: z.array(zClaimsPathComponent), + /** Whether the claim MUST be present in the payload. */ + mandatory: z.boolean().optional(), +} + +/** + * Internal claim — no `display`, no `value_type`. + * Per Section 3.5.2: "Claims without a `display` array MUST be internal values." + * Uses `.strict()` to reject extra fields like `value_type` that belong only on displayable claims. + */ +const zInternalClaimMetadata = z.object(zClaimBase).strict() + +/** + * Displayable claim — has `display`, optionally `value_type`. + * Per Section 3.5.2: "Claims that are relevant to the User's consent MUST include a `display` array." + */ +const zDisplayableClaimMetadata = z.object({ + ...zClaimBase, + /** How the Wallet Unit SHALL format the claim value for display. */ + value_type: z.string().optional(), + /** Localised display labels. */ + display: z.array(zClaimDisplayEntry), +}) + +/** + * TS12 Section 3.5.2 — Claim metadata object. + * + * Uses [OID4VCI] Appendix B.2 structure, extended with `value_type`. + * The `path` resolves against `transaction_data.payload`, not against the credential itself. + * + * Modelled as a union so TypeScript enforces: + * - `value_type` can only appear on claims with a `display` array. + */ +export const zClaimMetadata = z.union([zDisplayableClaimMetadata, zInternalClaimMetadata]) +export type ClaimMetadata = z.infer + +/** + * TS12 Section 3.5.3 — UI label locale entry. + * + * Per Section 3.5.4, entries that omit `locale` serve as defaults. + * The `value` string MAY contain placeholders `{}` referencing claims. + */ +export const zUiLabelEntry = z.object({ + locale: z.string().optional(), + value: z.string(), + value_type: z.string().optional(), +}) +export type UiLabelEntry = z.infer + +/** + * TS12 Section 3.5.3 — UI elements catalogue. + * + * Known identifiers: + * - `affirmative_action_label`: REQUIRED — confirmation button label. + * - `denial_action_label`: OPTIONAL — cancel button label. + * - `transaction_title`: OPTIONAL — transaction screen title. + * - `security_hint`: OPTIONAL — security hint displayed to User. + * + * Additional UI element identifiers MAY be defined. + */ +export const zUiLabels = z + .object({ + affirmative_action_label: z.array(zUiLabelEntry), + denial_action_label: z.array(zUiLabelEntry).optional(), + transaction_title: z.array(zUiLabelEntry).optional(), + security_hint: z.array(zUiLabelEntry).optional(), + }) + .catchall(z.array(zUiLabelEntry)) +export type UiLabels = z.infer + +/** + * TS12 Section 4.1 — Transaction data type entry (value within `transaction_data_types` object). + * + * Each entry describes the claims and UI labels for one transaction data type. + * Additional parameters MAY be defined; the Wallet Unit MUST ignore unrecognised ones. + */ +export const zTransactionDataType = z + .object({ + /** REQUIRED. Claim metadata array per Section 3.5.2. */ + claims: z.array(zClaimMetadata), + /** REQUIRED. UI elements catalogue per Section 3.5.3. */ + ui_labels: zUiLabels, + }) + .loose() +export type TransactionDataType = z.infer + +// ============================================================================= +// OID4VCI Section 12.2.4 — Credential metadata display +// ============================================================================= + +/** + * [OID4VCI] Section 12.2.4 — Credential display entry. + * + * Locale-tagged display metadata for the credential itself (card rendering). + * Uses `.loose()` to allow additional fields per spec extensions. + */ +export const zCredentialDisplayEntry = z + .object({ + name: z.string(), + locale: z.string().optional(), + description: z.string().optional(), + logo: z + .object({ + uri: z.string(), + alt_text: z.string().optional(), + }) + .optional(), + background_color: z.string().optional(), + text_color: z.string().optional(), + }) + .loose() +export type CredentialDisplayEntry = z.infer + +// ============================================================================= +// OID4VCI Section 12.2.4 + TS12 Section 4.1 — Full credential metadata +// ============================================================================= + +/** + * [OID4VCI] Section 12.2.4 credential metadata, extended with TS12 `transaction_data_types`. + * + * This is the `credential_metadata` object served at `credential_metadata_uri`. + * It contains: + * - `display`: credential-level display entries (name, logo, colors) per OID4VCI + * - `claims`: credential-level claim metadata per OID4VCI Appendix B.2 + * - `transaction_data_types`: SCA transaction type definitions per TS12 Section 4.1 + * + * Uses `.loose()` to allow additional OID4VCI fields without validating them. + */ +export const zCredentialMetadata = z + .object({ + /** Credential-level display entries per [OID4VCI] Section 12.2.4. */ + display: z.array(zCredentialDisplayEntry).optional(), + /** Credential-level claim metadata per [OID4VCI] Appendix B.2. */ + claims: z.array(zClaimMetadata).optional(), + /** Transaction data type definitions per TS12 Section 4.1. */ + transaction_data_types: z.record(z.string(), zTransactionDataType).optional(), + }) + .loose() +export type CredentialMetadata = z.infer + +/** + * SCA Attestation credential metadata — a `CredentialMetadata` where + * `transaction_data_types` is required (Section 3.1: SCA Attestations are + * identified by the presence of `transaction_data_types` keys). + */ +export const zScaCredentialMetadata = z + .object({ + display: z.array(zCredentialDisplayEntry).optional(), + claims: z.array(zClaimMetadata).optional(), + transaction_data_types: z.record(z.string(), zTransactionDataType), + }) + .loose() +export type ScaCredentialMetadata = z.infer diff --git a/src/validation/z-transaction-data-common.ts b/packages/validation/src/z-transaction-data-common.ts similarity index 100% rename from src/validation/z-transaction-data-common.ts rename to packages/validation/src/z-transaction-data-common.ts diff --git a/src/validation/z-transaction-data-funke.ts b/packages/validation/src/z-transaction-data-funke.ts similarity index 100% rename from src/validation/z-transaction-data-funke.ts rename to packages/validation/src/z-transaction-data-funke.ts diff --git a/packages/validation/src/z-transaction-data-ts12.ts b/packages/validation/src/z-transaction-data-ts12.ts new file mode 100644 index 0000000..93cee5d --- /dev/null +++ b/packages/validation/src/z-transaction-data-ts12.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { zBaseTransaction } from './z-transaction-data-common' + +/** + * TS12 Section 4.2 — SCA Transaction Data Entry. + * + * Extends the [OID4VP] transaction_data object with a `payload` parameter. + * The `type` field is a plain string — runtime routing via `ScaTransactionTypeMatcher` + * determines whether an entry is SCA-compatible (different standards use different + * URN prefixes, e.g. `urn:eudi:sca:`, `urn:paso:sca:`). + * + * The `payload` is a generic JSON object whose structure is defined by the `claims` + * metadata in the credential's `transaction_data_types` entry for the matching `type`. + */ +export const zScaTransactionDataEntry = zBaseTransaction.extend({ + /** Transaction data type identifier (e.g. 'urn:eudi:sca:...'). */ + type: z.string(), + /** REQUIRED. A JSON object containing the details for the transaction. */ + payload: z.record(z.string(), z.unknown()), +}) + +export type ScaTransactionDataEntry = z.infer diff --git a/packages/validation/src/z-transaction-data.ts b/packages/validation/src/z-transaction-data.ts new file mode 100644 index 0000000..a26a732 --- /dev/null +++ b/packages/validation/src/z-transaction-data.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' +import { zFunkeQesTransaction } from './z-transaction-data-funke' +import { zScaTransactionDataEntry } from './z-transaction-data-ts12' + +export const zTransactionDataEntry = zScaTransactionDataEntry.or(zFunkeQesTransaction) +export const zTransactionData = z.array(zTransactionDataEntry) + +export type TransactionDataEntry = z.infer +export type TransactionData = z.infer diff --git a/packages/validation/tsconfig.json b/packages/validation/tsconfig.json new file mode 100644 index 0000000..f8b80bc --- /dev/null +++ b/packages/validation/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "target": "ES2022", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "noEmitOnError": true, + "lib": ["ES2022"], + "types": [], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f63e736..10854cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@animo-id/eudi-wallet-ts12-credential-metadata-query': + specifier: workspace:* + version: link:packages/credential-metadata-query + '@animo-id/eudi-wallet-ts12-validation': + specifier: workspace:* + version: link:packages/validation zod: specifier: ^4.3.5 version: 4.3.5 @@ -46,6 +52,89 @@ importers: specifier: ~5.9.3 version: 5.9.3 + packages/credential-metadata-provider: + dependencies: + '@animo-id/eudi-wallet-ts12-validation': + specifier: workspace:* + version: link:../validation + '@hapi/accept': + specifier: ^6.0.3 + version: 6.0.3 + bcp-47-match: + specifier: ^2.0.3 + version: 2.0.3 + devDependencies: + tsdown: + specifier: ^0.18.4 + version: 0.18.4(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + packages/credential-metadata-query: + dependencies: + '@animo-id/eudi-wallet-ts12-validation': + specifier: workspace:* + version: link:../validation + '@credo-ts/core': + specifier: '*' + version: 0.6.1(typescript@5.9.3) + zod: + specifier: ^4.3.5 + version: 4.3.5 + devDependencies: + tsdown: + specifier: ^0.18.4 + version: 0.18.4(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + packages/dcql: + dependencies: + '@animo-id/eudi-wallet-ts12-resolver': + specifier: workspace:* + version: link:../resolver + '@animo-id/eudi-wallet-ts12-validation': + specifier: workspace:* + version: link:../validation + devDependencies: + tsdown: + specifier: ^0.18.4 + version: 0.18.4(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + packages/resolver: + dependencies: + '@animo-id/eudi-wallet-ts12-validation': + specifier: workspace:* + version: link:../validation + bcp-47-match: + specifier: ^2.0.3 + version: 2.0.3 + devDependencies: + tsdown: + specifier: ^0.18.4 + version: 0.18.4(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + packages/validation: + dependencies: + zod: + specifier: ^4.3.5 + version: 4.3.5 + devDependencies: + tsdown: + specifier: ^0.18.4 + version: 0.18.4(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + packages: '@2060.io/ffi-napi@4.0.9': @@ -408,6 +497,15 @@ packages: cpu: [x64] os: [win32] + '@hapi/accept@6.0.3': + resolution: {integrity: sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==} + + '@hapi/boom@10.0.1': + resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -762,7 +860,6 @@ packages: '@sphereon/kmp-mdoc-core@0.2.0-SNAPSHOT.26': resolution: {integrity: sha512-QXJ6R8ENiZV2rPMbn06cw5JKwqUYN1kzVRbYfONqE1PEXx1noQ4md7uxr2zSczi0ubKkNcbyYDNtIMTZIhGzmQ==} - bundledDependencies: [] '@sphereon/pex-models@2.3.2': resolution: {integrity: sha512-foFxfLkRwcn/MOp/eht46Q7wsvpQGlO7aowowIIb5Tz9u97kYZ2kz6K2h2ODxWuv5CRA7Q0MY8XUBGE2lfOhOQ==} @@ -890,6 +987,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -1739,6 +1839,7 @@ packages: tar@7.5.2: resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -2417,6 +2518,17 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@hapi/accept@6.0.3': + dependencies: + '@hapi/boom': 10.0.1 + '@hapi/hoek': 11.0.7 + + '@hapi/boom@10.0.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/hoek@11.0.7': {} + '@inquirer/external-editor@1.0.3(@types/node@22.15.29)': dependencies: chardet: 2.1.1 @@ -2944,6 +3056,8 @@ snapshots: base64-js@1.5.1: {} + bcp-47-match@2.0.3: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..2a7363d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - '.' + - 'packages/*' diff --git a/src/error.ts b/src/error.ts index ad76b28..976ce79 100644 --- a/src/error.ts +++ b/src/error.ts @@ -7,13 +7,3 @@ export class EudiWalletExtensionsError extends Error { } } } - -export class Ts12IntegrityError extends EudiWalletExtensionsError { - constructor(uri: string, integrity: string) { - super(`Invalid integrity for ${uri}, expected ${integrity}`) - this.name = 'Ts12IntegrityError' - if (Error.captureStackTrace) { - Error.captureStackTrace(this, Ts12IntegrityError) - } - } -} diff --git a/src/index.ts b/src/index.ts index bd3d103..0c01c96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,3 @@ export * from './error' export * from './merge-json' -export * from './validation/ts12' -export * from './validation/z-sca-attestation-ext' -export * from './validation/z-transaction-data' export { verifyOpenid4VpAuthorizationRequest } from './verifyOpenid4VpAuthorizationRequest' diff --git a/src/validation/ts12.ts b/src/validation/ts12.ts deleted file mode 100644 index ae0810e..0000000 --- a/src/validation/ts12.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { z } from 'zod' -import { Ts12IntegrityError } from '../error' -import { type MergeConfig, mergeJson } from '../merge-json' -import { - type ZScaAttestationExt, - zScaTransactionDataTypeClaims, - zScaTransactionDataTypeUiLabels, -} from './z-sca-attestation-ext' -import { ts12BuiltinSchemaValidators } from './z-transaction-data' - -export interface ResolvedTs12Metadata { - schema: string | object - claims: Array<{ - path: string[] - display: Array<{ name: string; locale?: string; logo?: string }> - }> - ui_labels: { - affirmative_action_label: Array<{ locale: string; value: string }> - denial_action_label?: Array<{ locale: string; value: string }> - transaction_title?: Array<{ locale: string; value: string }> - security_hint?: Array<{ locale: string; value: string }> - } -} - -async function fetchVerified( - uri: string, - schema: z.ZodType, - integrity?: string, - validateIntegrity?: (buf: ArrayBuffer, integrity: string) => boolean -): Promise { - const response = await fetch(uri) - if (!response.ok) { - throw new Error(`Failed to fetch URI: ${uri}`) - } - if (integrity && validateIntegrity && !validateIntegrity(await response.clone().arrayBuffer(), integrity)) { - throw new Ts12IntegrityError(uri, integrity) - } - return schema.parse(await response.json()) -} - -export async function resolveTs12TransactionDisplayMetadata( - metadata: ZScaAttestationExt, - type: string, - subtype?: string, - validateIntegrity?: (buf: ArrayBuffer, integrity: string) => boolean -): Promise { - if (!metadata.transaction_data_types) { - return undefined - } - - const typeMetadata = metadata.transaction_data_types.find((t) => t.type === type && t.subtype === subtype) - - if (!typeMetadata) { - return undefined - } - - const resolved: Partial = {} - - if (typeMetadata.type in ts12BuiltinSchemaValidators) { - resolved.schema = typeMetadata.type - } else if (typeMetadata.type.startsWith('http')) { - resolved.schema = await fetchVerified( - typeMetadata.type, - z.object({}), - typeMetadata['type#integrity'], - validateIntegrity - ) - } else { - throw new Error(`Unknown schema type for ${typeMetadata}`) - } - - if ('claims' in typeMetadata && typeMetadata.claims) { - resolved.claims = typeMetadata.claims - } else if ('claims_uri' in typeMetadata && typeMetadata.claims_uri) { - resolved.claims = await fetchVerified( - typeMetadata.claims_uri, - zScaTransactionDataTypeClaims, - typeMetadata['claims_uri#integrity'], - validateIntegrity - ) - } else { - throw new Error(`Unknown claims for ${typeMetadata}`) - } - - if ('ui_labels' in typeMetadata && typeMetadata.ui_labels) { - resolved.ui_labels = typeMetadata.ui_labels - } else if ('ui_labels_uri' in typeMetadata && typeMetadata.ui_labels_uri) { - resolved.ui_labels = await fetchVerified( - typeMetadata.ui_labels_uri, - zScaTransactionDataTypeUiLabels, - typeMetadata['ui_labels_uri#integrity'], - validateIntegrity - ) - } else { - throw new Error(`Unknown ui_labels for ${typeMetadata}`) - } - - return resolved as ResolvedTs12Metadata -} - -export const baseMergeConfig = { - fields: { - // [Display Metadata] - // RULE: COMPLETE REPLACEMENT - display: { - strategy: 'replace', - }, - - // [Claim Metadata] - // RULE: MERGE BY PATH - claims: { - strategy: 'merge', - arrayDiscriminant: 'path', - items: { - fields: { - // Constraint Rule: 'sd' (Selective Disclosure) - sd: { - validate: (target: unknown, source: unknown) => { - // Parent: "always" -> Child: MUST remain "always" - if (target === 'always' && source !== 'always') { - throw new Error("Constraint violation: 'sd' cannot change from 'always'") - } - // Parent: "never" -> Child: MUST remain "never" - if (target === 'never' && source !== 'never') { - throw new Error("Constraint violation: 'sd' cannot change from 'never'") - } - }, - }, - // Constraint Rule: 'mandatory' - mandatory: { - validate: (target: unknown, source: unknown) => { - // Parent: true -> Child: MUST remain true - if (target === true && source !== true) { - throw new Error("Constraint violation: 'mandatory' cannot change from true to false") - } - }, - }, - }, - }, - }, - }, -} as const satisfies MergeConfig - -export const ts12MergeConfig = mergeJson(baseMergeConfig, { - fields: { - transaction_data_types: { - arrayStrategy: 'append', // Default for unknown arrays - strategy: 'merge', - arrayDiscriminant: ['type', 'subtype'], - items: { - fields: { - claims: { - strategy: 'merge', - arrayDiscriminant: 'path', - items: { - fields: { - // Display: Merge by locale - display: { - strategy: 'merge', - arrayDiscriminant: 'locale', - }, - }, - }, - }, - ui_labels: { - strategy: 'merge', - // Use 'items' to apply configuration to all properties of the ui_labels object - items: { - strategy: 'merge', - arrayDiscriminant: 'locale', - }, - }, - }, - }, - }, - }, -} as const satisfies MergeConfig) satisfies MergeConfig diff --git a/src/validation/z-sca-attestation-ext.ts b/src/validation/z-sca-attestation-ext.ts deleted file mode 100644 index a2bc81f..0000000 --- a/src/validation/z-sca-attestation-ext.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { z } from 'zod' - -export const zScaTransactionDataTypeClaims = z.array( - z.object({ - /** The path to the claim within the transaction payload. */ - path: z.array(z.string()), - /** [ARF Annex 4] Localised display information for the claim. */ - display: z - .array( - z.object({ - /** Localised name of the claim (e.g., "Amount"). */ - name: z.string(), - /** [ISO639-1] Language code (e.g., "en"). */ - locale: z.string().optional(), - /** [RFC2397] Resolvable or Data URL of the claim icon. */ - logo: z.string().optional(), - }) - ) - .min(1), - }) -) - -export const zScaTransactionDataTypeUiLabels = z - .object({ - /** - * [REQUIRED] Label for the confirmation (consent) button. - * Max length: 30 characters. - */ - affirmative_action_label: z - .array( - z.object({ - /** [RFC5646] Language identifier (e.g., "en", "fr-CA"). */ - locale: z.string(), - /** Localised string value. Max length: 30 chars. */ - value: z.string().max(30), - }) - ) - .min(1), - - /** - * [OPTIONAL] Label for the denial (cancel) button. - * Max length: 30 characters. - */ - denial_action_label: z - .array( - z.object({ - /** [RFC5646] Language identifier. */ - locale: z.string(), - /** Localised string value. Max length: 30 chars. */ - value: z.string().max(30), - }) - ) - .min(1) - .optional(), - - /** - * [OPTIONAL] Title/headline for the transaction confirmation screen. - * Max length: 50 characters. - */ - transaction_title: z - .array( - z.object({ - /** [RFC5646] Language identifier. */ - locale: z.string(), - /** Localised string value. Max length: 50 chars. */ - value: z.string().max(50), - }) - ) - .min(1) - .optional(), - - /** - * [OPTIONAL] Security hint to be displayed to the User. - * Max length: 250 characters. - */ - security_hint: z - .array( - z.object({ - /** [RFC5646] Language identifier. */ - locale: z.string(), - /** Localised string value. Max length: 250 chars. */ - value: z.string().max(250), - }) - ) - .min(1) - .optional(), - }) - .catchall( - // [TS12] "Additional UI elements identifiers MAY be defined" - z.array( - z.object({ - locale: z.string(), - value: z.string(), - }) - ) - ) - -/** - * @name zScaAttestationExt - * @version EUDI TS12 v1.0 (05 December 2025) - * @description Defines metadata for SCA Attestations, including transaction types and UI localization. - * @see [EUDI TS12, Section 3] for VC Type Metadata requirements. - * @see [EUDI TS12, Section 4.1] for Metadata structure. - */ -export const zScaAttestationExt = z.object({ - /** - * [TS12 Section 3] Category the attestation belongs to. - * MUST be 'urn:eu:europa:ec:eudi:sua:sca' for SCA Attestations. - */ - category: z.string().optional(), - transaction_data_types: z.array( - z.intersection( - z.object({ - /** [TS12 4.1] URI (URL or URN) that references a [JSON Schema] defining the structure of the `payload` object within the `transaction_data` object. */ - type: z.string(), - /** [TS12 4.1] Hash of the document referenced by `type`. MUST be present if `type` is a URL and integrity protection is desired. */ - 'type#integrity': z.string().optional(), - /** [TS12 4.1] A string that can be used to further categorize the transaction type. */ - subtype: z.string().optional(), - }), - z.intersection( - z.union([ - z.object({ - /** [TS12 3.3.2] Transaction Data Claim Metadata. MUST NOT be used if claims_uri is present. */ - claims: zScaTransactionDataTypeClaims, - }), - z.object({ - /** [TS12 3.3.2] URI referencing an external claims metadata document. MUST NOT be used if claims is present. */ - claims_uri: z.url(), - 'claims_uri#integrity': z.string().optional(), - }), - ]), - z.union([ - z.object({ - /** [TS12 3.3.3] Localised UI element values. MUST NOT be used if ui_labels_uri is present. */ - ui_labels: zScaTransactionDataTypeUiLabels, - }), - z.object({ - /** [TS12 3.3.3] URI referencing external UI labels. MUST NOT be used if ui_labels is present. */ - ui_labels_uri: z.string().url(), - 'ui_labels_uri#integrity': z.string().optional(), - }), - ]) - ) - ) - ), -}) - -export type ZScaAttestationExt = z.infer diff --git a/src/validation/z-transaction-data-ts12.ts b/src/validation/z-transaction-data-ts12.ts deleted file mode 100644 index f8c0da7..0000000 --- a/src/validation/z-transaction-data-ts12.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { z } from 'zod' -import { zBaseTransaction } from './z-transaction-data-common' - -// ============================================================================= -// 1. TS12 PAYLOAD SCHEMAS (Nested Objects) -// Source: EUDI TS12 Section 4.3 "Payload Object" -// ============================================================================= - -/** - * **TS12 Payment Payload** - * * The business data strictly defined for Payments. - * * @see EUDI TS12 Section 4.3.1 "Payment Confirmation" - */ -export const zPaymentPayload = z - .object({ - /** - * **Transaction ID** - * Unique identifier of the Relying Party's interaction with the User. - * @example "8D8AC610-566D-4EF0-9C22-186B2A5ED793" - */ - transaction_id: z.string().min(1).max(36).describe("Unique identifier of the Relying Party's interaction"), - - /** - * **Date Time** - * ISO 8601 date and time when the Relying Party started to interact with the User. - * @example "2025-11-13T20:20:39+00:00" - */ - date_time: z.iso.datetime().optional(), - - /** - * **Payee** - * Object holding the Payee (Merchant) details. - */ - payee: z.object({ - /** - * **Payee Name** - * Name of the Payee to whom the payment is being made. - */ - name: z.string(), - - /** - * **Payee ID** - * An identifier of the Payee understood by the payment system. - */ - id: z.string(), - - /** - * **Logo** - * Resolvable URL or Data URI (RFC 2397) of the Payee logo. - */ - logo: z.url().optional(), - - /** - * **Website** - * Resolvable URL of the Payee's website. - */ - website: z.url().optional(), - }), - - /** - * **Currency** - * 3-letter currency code (ISO 4217). - */ - currency: z.string().regex(/^[A-Z]{3}$/), - - /** - * **Amount** - * The monetary value of the transaction. - */ - amount: z.number(), - - /** - * **Amount Estimated** - */ - amount_estimated: z.boolean().optional(), - - /** - * **Amount Earmarked** - */ - amount_earmarked: z.boolean().optional(), - - /** - * **SCT Inst** - */ - sct_inst: z.boolean().optional(), - - /** - * **PISP Details** - * If present, indicates that the payment is being facilitated by a PISP. - */ - pisp: z - .object({ - /** - * **Legal Name** - * Legal name of the PISP. - */ - legal_name: z.string(), - - /** - * **Brand Name** - * Brand name of the PISP. - */ - brand_name: z.string(), - - /** - * **Domain Name** - * Domain name of the PISP as secured by the eIDAS QWAC certificate. - */ - domain_name: z.string(), - }) - .optional(), - - /** - * **Execution Date** - * ISO 8601 date of the payment's execution. MUST NOT be present when recurrence is present. - * MUST NOT lie in the past. - */ - execution_date: z.iso - .datetime() - .optional() - .refine( - (date) => { - if (!date) return true - return new Date(date) >= new Date() - }, - { message: 'Execution date must not be in the past' } - ), - - /** - * **Recurrence** - * Details for recurring payments. - */ - recurrence: z - .object({ - /** - * **Start Date** - * ISO 8601 date when the recurrence starts. - */ - start_date: z.iso.datetime().optional(), - - /** - * **End Date** - * ISO 8601 date when the recurrence ends. - */ - end_date: z.iso.datetime().optional(), - - /** - * **Number** - */ - number: z.number().int().optional(), - - /** - * **Frequency** - * ISO 20022 Frequency Code. - */ - frequency: z.enum([ - 'INDA', - 'DAIL', - 'WEEK', - 'TOWK', - 'TWMN', - 'MNTH', - 'TOMN', - 'QUTR', - 'FOMN', - 'SEMI', - 'YEAR', - 'TYEA', - ]), - - /** - * **MIT Options (Merchant Initiated Transaction)** - */ - mit_options: z - .object({ - /** - * **Amount Variable** - * If true, future amounts may vary. - */ - amount_variable: z.boolean().optional(), - - /** - * **Minimum Amount** - * Minimum expected amount for future transactions. - */ - min_amount: z.number().optional(), - - /** - * **Maximum Amount** - */ - max_amount: z.number().optional(), - - /** - * **Total Amount** - */ - total_amount: z.number().optional(), - - /** - * **Initial Amount** - */ - initial_amount: z.number().optional(), - - /** - * **Initial Amount Number** - */ - initial_amount_number: z.number().int().optional(), - - /** - * **APR** - */ - apr: z.number().optional(), - }) - .optional(), - }) - .optional(), - }) - .refine((data) => !(data.recurrence && data.execution_date), { - message: 'Execution date must not be present when recurrence is present', - path: ['execution_date'], - }) - -/** - * **TS12 Generic Payload** - * * @see EUDI TS12 Section 4.3.2 - */ -export const zGenericPayload = z - .object({ - /** - * **Transaction ID** - * Unique identifier of the Relying Party's interaction. - * @example "8D8AC610-566D-4EF0-9C22-186B2A5ED793" - */ - transaction_id: z.string().min(1).max(36), - - /** - * **Payment Payload** - * Nested payment object to leverage data for MITs. - */ - payment_payload: zPaymentPayload.optional(), - }) - .catchall(z.string().max(40).nullable()) - .refine( - (data) => { - return Object.keys(data).length <= 11 - }, - { message: 'Total number of properties is limited to 11' } - ) - -export type Ts12PaymentPayload = z.infer -export type Ts12GenericPayload = z.infer - -export const URN_SCA_PAYMENT = 'urn:eudi:sca:payment:1' -export const URN_SCA_GENERIC = 'urn:eudi:sca:generic:1' - -// ============================================================================= -// 2. ROOT TRANSACTION DATA OBJECT (OpenID4VP Envelope) -// Source: OpenID4VP Section 5.1 & TS12 Section 4.3 -// ============================================================================= - -export const zTs12PaymentTransaction = zBaseTransaction.extend({ - type: z.literal(URN_SCA_PAYMENT), - subtype: z.undefined(), - payload: zPaymentPayload, -}) - -export const zTs12GenericTransaction = zBaseTransaction.extend({ - type: z.literal(URN_SCA_GENERIC), - subtype: z.string(), - payload: zGenericPayload, -}) - -export const zTs12FallbackTransaction = zBaseTransaction.extend({ - subtype: z.string().optional(), - payload: z.unknown(), -}) - -/** - * **TS12 Transaction** - * @see TS12 Section 4.3 - */ -export const zTs12Transaction = z.union([zTs12PaymentTransaction, zTs12GenericTransaction, zTs12FallbackTransaction]) - -export type Ts12TransactionDataEntry = z.infer diff --git a/src/validation/z-transaction-data.ts b/src/validation/z-transaction-data.ts deleted file mode 100644 index a73034c..0000000 --- a/src/validation/z-transaction-data.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod' -import { zFunkeQesTransaction } from './z-transaction-data-funke' -import { - URN_SCA_GENERIC, - URN_SCA_PAYMENT, - zGenericPayload, - zPaymentPayload, - zTs12Transaction, -} from './z-transaction-data-ts12' - -export * from './z-transaction-data-funke' -export * from './z-transaction-data-ts12' - -export const zTransactionDataEntry = zTs12Transaction.or(zFunkeQesTransaction) -export const zTransactionData = z.array(zTransactionDataEntry) - -export type TransactionDataEntry = z.infer -export type TransactionData = z.infer - -export const ts12BuiltinSchemaValidators = { - [URN_SCA_PAYMENT]: zPaymentPayload, - [URN_SCA_GENERIC]: zGenericPayload, -} as const diff --git a/tests/credential-metadata-provider.test.ts b/tests/credential-metadata-provider.test.ts new file mode 100644 index 0000000..6bf53ef --- /dev/null +++ b/tests/credential-metadata-provider.test.ts @@ -0,0 +1,573 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import { CredentialMetadataProvider } from '../packages/credential-metadata-provider/src/credential-metadata-provider' +import { + buildLocaleKey, + collectMetadataLocales, + defaultLocaleCanonicalizer, + deriveAllowedLocales, + filterByAllowList, + filterMetadataByLocales, + localeFullyResolves, + negotiateMediaType, + parseAcceptLanguage, +} from '../packages/credential-metadata-provider/src/filter-locale' +import type { CredentialMetadata } from '../packages/validation/src/z-sca-attestation-ext' + +// ============================================================================= +// Fixtures — TS12 Annex D.8 style credential metadata +// ============================================================================= + +const annexDMetadata: CredentialMetadata = { + display: [ + { + name: 'SuperBank Payment', + locale: 'en', + logo: { uri: 'https://issuer.superbank.eu/logo.png', alt_text: 'SuperBank logo' }, + background_color: '#003366', + text_color: '#ffffff', + }, + { + name: 'SuperBank Zahlung', + locale: 'de', + logo: { uri: 'https://issuer.superbank.eu/logo.png', alt_text: 'SuperBank-Logo' }, + background_color: '#003366', + text_color: '#ffffff', + }, + ], + claims: [ + { + path: ['payment_network'], + display: [ + { locale: 'en', name: 'Payment network' }, + { locale: 'de', name: 'Zahlungsnetzwerk' }, + ], + }, + ], + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [ + { path: ['transaction_id'], mandatory: true }, + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [ + { locale: 'en', name: 'Amount' }, + { locale: 'de', name: 'Betrag' }, + ], + }, + { + path: ['payee', 'name'], + mandatory: true, + display: [ + { locale: 'en', name: 'Payee' }, + { locale: 'de', name: 'Empfänger' }, + ], + }, + { path: ['payee', 'id'], mandatory: true }, + ], + ui_labels: { + affirmative_action_label: [ + { locale: 'en', value: 'Confirm Payment' }, + { locale: 'de', value: 'Zahlung bestätigen' }, + ], + denial_action_label: [ + { locale: 'en', value: 'Cancel' }, + { locale: 'de', value: 'Abbrechen' }, + ], + }, + }, + }, +} + +/** Metadata with default (no-locale) entries — RFC 4647 Section 3.4 default value. */ +const metadataWithDefaults: CredentialMetadata = { + display: [{ name: 'Default Card' }, { name: 'EN Card', locale: 'en' }], + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [{ path: ['amount'], display: [{ name: 'Amount (default)' }, { locale: 'en', name: 'Amount' }] }], + ui_labels: { affirmative_action_label: [{ value: 'OK (default)' }, { locale: 'en', value: 'Confirm' }] }, + }, + }, +} + +/** Metadata with inconsistent locale coverage — de missing from one display array. */ +const inconsistentMetadata: CredentialMetadata = { + display: [ + { name: 'Card', locale: 'en' }, + { name: 'Karte', locale: 'de' }, + ], + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [{ path: ['amount'], display: [{ locale: 'en', name: 'Amount' }] }], + ui_labels: { affirmative_action_label: [{ locale: 'en', value: 'OK' }] }, + }, + }, +} + +// ============================================================================= +// Mock provider helpers +// ============================================================================= + +function makeStore() { + const jwtCache = new Map() + return { + getCredentialInfo: async (id: string) => + id === 'card' + ? { + credentialType: 'https://superbank.eu/sca/payment', + format: 'dc+sd-jwt', + credentialMetadataUri: 'https://issuer.superbank.eu/credential-metadata/payment', + updatedAt: 1, + } + : undefined, + getCredentialMetadata: async (id: string) => (id === 'card' ? annexDMetadata : undefined), + getSignedJwt: async (_id: string, key: string) => jwtCache.get(key), + saveSignedJwt: async (_id: string, key: string, jwt: string) => { + jwtCache.set(key, jwt) + }, + } +} + +const fakeSigner = async (payload: Record) => `header.${btoa(JSON.stringify(payload))}.signature` + +function makeProvider() { + return new CredentialMetadataProvider({ + store: makeStore(), + signer: fakeSigner, + issuerIdentifier: 'https://issuer.superbank.eu', + expiresInSeconds: 2592000, + }) +} + +// ============================================================================= +// TS12 Section 5 — Content negotiation (Accept header) +// ============================================================================= +// "If the Accept header is absent or does not express a preference, the Credential +// Issuer SHALL default to application/json." +// "Accept: application/json — SHALL return the credential metadata as a plain JSON object." +// "Accept: application/jwt — SHALL return the credential metadata as a signed JWT." + +describe('TS12 Section 5 — Accept header content negotiation', () => { + it('absent Accept header defaults to application/json', async () => { + const res = await makeProvider().handle('card', {}) + assert.strictEqual(res.status, 200) + assert.strictEqual(res.contentType, 'application/json') + JSON.parse(res.body) // must be valid JSON + }) + + it('Accept: application/json returns JSON', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json' }) + assert.strictEqual(res.contentType, 'application/json') + }) + + it('Accept: application/jwt returns signed JWT', async () => { + const res = await makeProvider().handle('card', { accept: 'application/jwt' }) + assert.strictEqual(res.contentType, 'application/jwt') + assert.strictEqual(res.body.split('.').length, 3) + }) + + it('Accept: application/jwt;q=0 excludes JWT (RFC 9110 §12.5.1: q=0 not acceptable)', async () => { + const res = await makeProvider().handle('card', { accept: 'application/jwt;q=0, application/json' }) + assert.strictEqual(res.contentType, 'application/json') + }) + + it('credential not found returns 404', async () => { + const res = await makeProvider().handle('unknown', { accept: 'application/json' }) + assert.strictEqual(res.status, 404) + assert.strictEqual(JSON.parse(res.body).error, 'not_found') + }) + + it('malformed Accept header returns 400', async () => { + const res = await makeProvider().handle('card', { accept: 'application/jwt;q=abc;q=xyz' }) + // @hapi/accept may or may not throw on this — if it doesn't throw, the provider should still respond + assert.ok(res.status === 200 || res.status === 400) + }) +}) + +// ============================================================================= +// TS12 Section 5 — JWT payload structure +// ============================================================================= +// "The JWT payload SHALL include the following claims: +// iss, sub, format, iat, exp, credential_metadata_uri, credential_metadata" + +describe('TS12 Section 5 — JWT payload claims', () => { + it('contains all REQUIRED claims per Section 5', async () => { + const res = await makeProvider().handle('card', { accept: 'application/jwt', acceptLanguage: 'en' }) + const payload = JSON.parse(atob(res.body.split('.')[1])) + + assert.strictEqual(payload.iss, 'https://issuer.superbank.eu') + assert.strictEqual(payload.sub, 'https://superbank.eu/sca/payment') + assert.strictEqual(payload.format, 'dc+sd-jwt') + assert.ok(typeof payload.iat === 'number') + assert.ok(typeof payload.exp === 'number') + assert.ok(payload.exp > payload.iat) + assert.strictEqual(payload.credential_metadata_uri, 'https://issuer.superbank.eu/credential-metadata/payment') + assert.ok(payload.credential_metadata !== undefined) + }) + + it('exp = iat + expiresInSeconds', async () => { + const res = await makeProvider().handle('card', { accept: 'application/jwt', acceptLanguage: 'en' }) + const payload = JSON.parse(atob(res.body.split('.')[1])) + assert.strictEqual(payload.exp - payload.iat, 2592000) + }) + + it('credential_metadata contains display and transaction_data_types', async () => { + const res = await makeProvider().handle('card', { accept: 'application/jwt', acceptLanguage: 'en' }) + const payload = JSON.parse(atob(res.body.split('.')[1])) + const cm = payload.credential_metadata + assert.ok(Array.isArray(cm.display)) + assert.ok(cm.transaction_data_types !== undefined) + }) +}) + +// ============================================================================= +// TS12 Section 5 / RFC 9110 §12.5.4 — Accept-Language handling +// ============================================================================= +// "the Wallet Unit MAY include an Accept-Language header per [OID4VCI] Section 12.2.2" +// RFC 9110 §12.5.4: absent Accept-Language means any language acceptable +// RFC 9110 §12.5.4: * matches any language + +describe('TS12 Section 5 — Accept-Language handling', () => { + it('absent Accept-Language serves metadata (any language acceptable per RFC 9110)', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json' }) + assert.strictEqual(res.status, 200) + }) + + it('Accept-Language: * serves metadata (accept any per RFC 9110 §12.5.4)', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json', acceptLanguage: '*' }) + assert.strictEqual(res.status, 200) + }) + + it('Accept-Language: en filters to English only', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json', acceptLanguage: 'en' }) + const cm = JSON.parse(res.body) + assert.strictEqual(cm.display.length, 1) + assert.strictEqual(cm.display[0].locale, 'en') + }) + + it('Accept-Language: de filters to German only', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json', acceptLanguage: 'de' }) + const cm = JSON.parse(res.body) + assert.strictEqual(cm.display.length, 1) + assert.strictEqual(cm.display[0].locale, 'de') + }) + + it('unmatched Accept-Language returns 404 with supported_locales', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json', acceptLanguage: 'zh-CN' }) + assert.strictEqual(res.status, 404) + const body = JSON.parse(res.body) + assert.strictEqual(body.error, 'no_matching_locale') + assert.ok(Array.isArray(body.supported_locales)) + assert.ok(body.supported_locales.length > 0) + }) + + it('Accept-Language: en;q=0 (nothing acceptable) returns 404', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json', acceptLanguage: 'en;q=0' }) + assert.strictEqual(res.status, 404) + }) + + it('malformed Accept-Language returns 400', async () => { + const res = await makeProvider().handle('card', { accept: 'application/json', acceptLanguage: 'en;q=abc;q=xyz' }) + assert.strictEqual(res.status, 400) + assert.strictEqual(JSON.parse(res.body).error, 'invalid_accept_language') + }) + + it('quality values determine preference: de;q=0.5, en;q=0.9 picks en', async () => { + const res = await makeProvider().handle('card', { + accept: 'application/json', + acceptLanguage: 'de;q=0.5, en;q=0.9', + }) + const cm = JSON.parse(res.body) + // defaultLocaleCanonicalizer picks first (highest q) locale's primary subtag + assert.strictEqual(cm.display.length, 1) + assert.strictEqual(cm.display[0].locale, 'en') + }) +}) + +// ============================================================================= +// RFC 4647 Section 3.4 — Basic Lookup +// ============================================================================= +// "the range is truncated by removing the last subtag" +// "Tags and ranges are to be matched in a case-insensitive manner" +// "If the subtag that is removed is a single character, the preceding subtag is also removed" + +describe('RFC 4647 §3.4 — Basic Lookup (locale matching)', () => { + it('range en-GB matches tag en (range truncation: en-GB → en)', () => { + assert.strictEqual( + localeFullyResolves('en-GB', { + display: [{ name: 'Card', locale: 'en' }], + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [{ path: ['x'], display: [{ locale: 'en', name: 'X' }] }], + ui_labels: { affirmative_action_label: [{ locale: 'en', value: 'OK' }] }, + }, + }, + }), + true + ) + }) + + it('range en does NOT match tag en-GB (tags are never truncated)', () => { + assert.strictEqual( + localeFullyResolves('en', { + display: [{ name: 'Card', locale: 'en-GB' }], + transaction_data_types: { + 'urn:eudi:sca:eu.europa.ec:payment:single:1': { + claims: [{ path: ['x'], display: [{ locale: 'en-GB', name: 'X' }] }], + ui_labels: { affirmative_action_label: [{ locale: 'en-GB', value: 'OK' }] }, + }, + }, + }), + false + ) + }) + + it('default entry (no locale) matches when lookup exhausts all truncations (RFC 4647 §3.4 default value)', () => { + assert.strictEqual(localeFullyResolves('fr', metadataWithDefaults), true) + }) + + it('no default and no match → locale does not resolve', () => { + assert.strictEqual(localeFullyResolves('fr', inconsistentMetadata), false) + }) + + it('matching is case-insensitive (RFC 4647: "case-insensitive manner")', () => { + assert.strictEqual(localeFullyResolves('EN', annexDMetadata), true) + assert.strictEqual(localeFullyResolves('De', annexDMetadata), true) + }) +}) + +// ============================================================================= +// TS12 Section 3.5.4 — Locale resolvability +// ============================================================================= +// "If every display array produces a match, the current locale is selected" +// "If any display array produces no match, discard all matches" + +describe('TS12 §3.5.4 — Locale resolvability', () => { + it('locale resolves when all display arrays have it (Annex D: en and de)', () => { + assert.strictEqual(localeFullyResolves('en', annexDMetadata), true) + assert.strictEqual(localeFullyResolves('de', annexDMetadata), true) + }) + + it('locale fails to resolve when ANY display array lacks it', () => { + // inconsistentMetadata: 'de' is in credential display but missing from claims and ui_labels + assert.strictEqual(localeFullyResolves('de', inconsistentMetadata), false) + }) + + it('deriveAllowedLocales only returns fully resolvable locales', () => { + const allowed = deriveAllowedLocales(inconsistentMetadata) + assert.ok(allowed.includes('en')) + assert.ok(!allowed.includes('de')) + }) + + it('deriveAllowedLocales warns for non-resolvable locales', () => { + const warnings: string[] = [] + deriveAllowedLocales(inconsistentMetadata, { warn: (msg) => warnings.push(msg) }) + assert.strictEqual(warnings.length, 1) + assert.ok(warnings[0].includes('de')) + }) + + it('collectMetadataLocales returns all unique locale tags', () => { + const locales = collectMetadataLocales(annexDMetadata) + assert.ok(locales.includes('en')) + assert.ok(locales.includes('de')) + assert.strictEqual(locales.length, 2) + }) +}) + +// ============================================================================= +// TS12 Section 5 — Locale filtering of credential_metadata +// ============================================================================= +// The provider filters display arrays to the requested locale. +// Per Section 3.5.4: "Entries that omit locale are default entries" — always kept. + +describe('TS12 §5 — Metadata locale filtering', () => { + it('filters credential-level display to requested locale (OID4VCI §12.2.4)', () => { + const result = filterMetadataByLocales(annexDMetadata, ['en']) + assert.strictEqual(result.display?.length, 1) + assert.strictEqual(result.display?.[0].name, 'SuperBank Payment') + }) + + it('filters credential-level claims display', () => { + const result = filterMetadataByLocales(annexDMetadata, ['de']) + const claim = result.claims?.[0] + assert.ok(claim && 'display' in claim) + if ('display' in claim) { + assert.strictEqual(claim.display.length, 1) + assert.strictEqual(claim.display[0].name, 'Zahlungsnetzwerk') + } + }) + + it('filters transaction_data_types claims display', () => { + const result = filterMetadataByLocales(annexDMetadata, ['en']) + const tdType = result.transaction_data_types?.['urn:eudi:sca:eu.europa.ec:payment:single:1'] + assert.ok(tdType) + const amountClaim = tdType.claims[1] + assert.ok('display' in amountClaim) + if ('display' in amountClaim) { + assert.strictEqual(amountClaim.display.length, 1) + assert.strictEqual(amountClaim.display[0].name, 'Amount') + } + }) + + it('filters transaction_data_types ui_labels', () => { + const result = filterMetadataByLocales(annexDMetadata, ['de']) + const tdType = result.transaction_data_types?.['urn:eudi:sca:eu.europa.ec:payment:single:1'] + assert.ok(tdType) + assert.strictEqual(tdType.ui_labels.affirmative_action_label.length, 1) + assert.strictEqual(tdType.ui_labels.affirmative_action_label[0].value, 'Zahlung bestätigen') + }) + + it('preserves internal claims (no display) unchanged', () => { + const result = filterMetadataByLocales(annexDMetadata, ['en']) + const tdType = result.transaction_data_types?.['urn:eudi:sca:eu.europa.ec:payment:single:1'] + assert.ok(tdType) + const txIdClaim = tdType.claims[0] + assert.ok(!('display' in txIdClaim)) + assert.strictEqual(txIdClaim.mandatory, true) + }) + + it('always keeps default (no-locale) entries per RFC 4647 §3.4', () => { + const result = filterMetadataByLocales(metadataWithDefaults, ['en']) + assert.strictEqual(result.display?.length, 2) // default + en + assert.ok(result.display?.some((d) => d.name === 'Default Card')) + }) + + it('keeps only defaults when locale has no match', () => { + const result = filterMetadataByLocales(metadataWithDefaults, ['zh']) + assert.strictEqual(result.display?.length, 1) // default only + assert.strictEqual(result.display?.[0].name, 'Default Card') + }) + + it('does not mutate original metadata', () => { + const originalLen = annexDMetadata.display?.length + const result = filterMetadataByLocales(annexDMetadata, ['en']) + assert.notStrictEqual(result, annexDMetadata) + assert.strictEqual(annexDMetadata.display?.length, originalLen) + }) +}) + +// ============================================================================= +// TS12 Section 5 — Accept header media type negotiation +// ============================================================================= + +describe('negotiateMediaType (via @hapi/accept)', () => { + const available = ['application/jwt', 'application/json'] + + it('exact match: application/jwt', () => { + assert.strictEqual(negotiateMediaType('application/jwt', available), 'application/jwt') + }) + + it('exact match: application/json', () => { + assert.strictEqual(negotiateMediaType('application/json', available), 'application/json') + }) + + it('q=0 excludes a type (RFC 9110 §12.5.1)', () => { + assert.strictEqual(negotiateMediaType('application/jwt;q=0, application/json', available), 'application/json') + }) + + it('higher q wins', () => { + assert.strictEqual(negotiateMediaType('application/json;q=0.5, application/jwt', available), 'application/jwt') + }) +}) + +// ============================================================================= +// TS12 Section 5 — Accept-Language parsing +// ============================================================================= + +describe('parseAcceptLanguage (via @hapi/accept)', () => { + it('returns locales sorted by quality', () => { + const { locales } = parseAcceptLanguage('de, en;q=0.8') + assert.strictEqual(locales[0], 'de') + }) + + it('* sets acceptsAll', () => { + const { acceptsAll } = parseAcceptLanguage('*') + assert.strictEqual(acceptsAll, true) + }) + + it('* is excluded from locales list', () => { + const { locales } = parseAcceptLanguage('*') + assert.ok(!locales.includes('*')) + }) + + it('q=0 excludes a locale (RFC 9110 §12.5.4)', () => { + const { locales } = parseAcceptLanguage('en, de;q=0') + assert.ok(!locales.includes('de')) + }) + + it('throws on malformed header', () => { + assert.throws(() => parseAcceptLanguage('en;q=abc;q=xyz')) + }) +}) + +// ============================================================================= +// Locale canonicalization + cache key +// ============================================================================= + +describe('defaultLocaleCanonicalizer', () => { + it('picks first locale primary subtag: [en-us, de-de] → [en]', () => { + assert.deepStrictEqual(defaultLocaleCanonicalizer(['en-us', 'de-de']), ['en']) + }) + + it('handles locale without subtag: [de] → [de]', () => { + assert.deepStrictEqual(defaultLocaleCanonicalizer(['de', 'en']), ['de']) + }) + + it('empty input → empty output', () => { + assert.deepStrictEqual(defaultLocaleCanonicalizer([]), []) + }) +}) + +describe('buildLocaleKey', () => { + it('sorts and deduplicates', () => { + assert.strictEqual(buildLocaleKey(['de', 'en', 'de']), 'de,en') + }) + + it('empty → empty string', () => { + assert.strictEqual(buildLocaleKey([]), '') + }) +}) + +// ============================================================================= +// filterByAllowList — RFC 4647 Lookup against allowed locales +// ============================================================================= + +describe('filterByAllowList', () => { + it('keeps locales that match allowed via RFC 4647 Lookup', () => { + const result = filterByAllowList(['en', 'fr'], ['en', 'de']) + assert.deepStrictEqual(result, ['en']) + }) + + it('range truncation: en-GB matches allowed en', () => { + const result = filterByAllowList(['en-GB'], ['en']) + assert.deepStrictEqual(result, ['en-GB']) + }) + + it('returns empty when nothing matches', () => { + const result = filterByAllowList(['fr'], ['en', 'de']) + assert.deepStrictEqual(result, []) + }) +}) + +// ============================================================================= +// TS12 Section 5 — JWT caching +// ============================================================================= + +describe('TS12 §5 — JWT caching', () => { + it('second request for same locale returns cached JWT', async () => { + const provider = makeProvider() + const h = { accept: 'application/jwt', acceptLanguage: 'en' } + const r1 = await provider.handle('card', h) + const r2 = await provider.handle('card', h) + assert.strictEqual(r1.body, r2.body) + }) + + it('different locales produce different JWTs', async () => { + const provider = makeProvider() + const r1 = await provider.handle('card', { accept: 'application/jwt', acceptLanguage: 'en' }) + const r2 = await provider.handle('card', { accept: 'application/jwt', acceptLanguage: 'de' }) + assert.notStrictEqual(r1.body, r2.body) + }) +}) diff --git a/tests/dcql.test.ts b/tests/dcql.test.ts new file mode 100644 index 0000000..7387770 --- /dev/null +++ b/tests/dcql.test.ts @@ -0,0 +1,1337 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import type { ScaCredentialMetadata } from '@animo-id/eudi-wallet-ts12-validation' +import { + bestEffortDecompose, + buildCoOccurrences, + decomposeTransposable, + generateCartesianProduct, + type SlotDecomposition, + verifyCartesianProduct, +} from '../packages/dcql/src/cartesian' +import { + collectScaCredentialQueryIds, + findFirstSatisfiableOption, + isOptionSatisfiable, + orderSlotsByReference, + partitionOptions, + resolveNonScaCredentialSet, + resolveScaCredentialSet, +} from '../packages/dcql/src/resolve-credential-set' +import { + canResolveCredentialForLocale, + findFirstNonScaTransactionData, + isTargetedByTransactionData, + resolveAllMatchedCredentials, + resolveFirstMatchScaTransactionData, +} from '../packages/dcql/src/resolve-credentials' +import { + buildCredentialQueryMap, + hasScaTransactionData, + resolveDcql, + validateDcqlQueryStructure, + validateTransactionDataCredentialSet, +} from '../packages/dcql/src/resolve-dcql' +import { err, isErr, isOk, ok } from '../packages/dcql/src/result' +import type { + CredentialMatcher, + DcqlCredentialQuery, + DcqlCredentialSetQuery, + MatchedCredential, + TransactionDataInput, + WalletConfiguration, +} from '../packages/dcql/src/types' + +// ============================================================================= +// Mock helpers +// ============================================================================= + +function makeConfig(overrides?: Partial): WalletConfiguration { + return { + locales: ['en'], + valueTypeResolvers: { + string: (v: string) => v, + iso_currency_amount: (v: string) => v, + }, + mode: 'light', + ...overrides, + } +} + +function makeQuery(id: string, format = 'dc+sd-jwt'): DcqlCredentialQuery { + return { + id, + format, + meta: { vct_values: [`https://example.com/${id}`] }, + } +} + +function makeCredential(credentialId: string): MatchedCredential { + return { credentialId } +} + +function makeScaCredential(credentialId: string, typeKeys: string[]): MatchedCredential { + const transaction_data_types: Record = {} + for (const key of typeKeys) { + transaction_data_types[key] = { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Amount' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm' }], + }, + } + } + return { + credentialId, + scaMetadata: { transaction_data_types } as ScaCredentialMetadata, + } +} + +function makeTransactionData( + type: string, + credentialIds: string[], + payload: Record = {} +): TransactionDataInput { + return { type, credential_ids: credentialIds, payload } +} + +function makeMatcher(available: Record): CredentialMatcher { + return (query: DcqlCredentialQuery) => available[query.id] ?? [] +} + +/** buildCredentialQueryMap that asserts no duplicates (for test fixtures with unique IDs). */ +function buildQueryMap(queries: DcqlCredentialQuery[]): Map { + const map = buildCredentialQueryMap(queries) + assert.ok(map, 'Test fixture has duplicate query IDs') + return map +} + +// ============================================================================= +// Result helpers +// ============================================================================= + +describe('Result helpers', () => { + it('ok wraps a value', () => { + const r = ok(42) + assert.deepStrictEqual(r, { ok: true, value: 42 }) + }) + + it('err wraps an error', () => { + const r = err('fail') + assert.deepStrictEqual(r, { ok: false, error: 'fail' }) + }) + + it('isOk returns true for ok results', () => { + assert.strictEqual(isOk(ok(1)), true) + assert.strictEqual(isOk(err('x')), false) + }) + + it('isErr returns true for err results', () => { + assert.strictEqual(isErr(err('x')), true) + assert.strictEqual(isErr(ok(1)), false) + }) +}) + +// ============================================================================= +// Section 3.3 — First-match rule (resolveFirstMatchScaTransactionData) +// ============================================================================= + +describe('Section 3.3 — First-match rule (resolveFirstMatchScaTransactionData)', () => { + const scaMetadataBothVersions: ScaCredentialMetadata = { + transaction_data_types: { + 'urn:eudi:sca:com.example.pay:transaction:2': { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Amount v2' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm v2' }], + }, + }, + 'urn:eudi:sca:com.example.pay:transaction:1': { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Amount v1' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm v1' }], + }, + }, + }, + } + + const scaMetadataV1Only: ScaCredentialMetadata = { + transaction_data_types: { + 'urn:eudi:sca:com.example.pay:transaction:1': { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Amount v1' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm v1' }], + }, + }, + }, + } + + it('selects the first compatible entry in array order', () => { + // v2 entry is at index 0, v1 at index 1. Credential supports both. + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:2', ['sca_card'], { amount: 'EUR 100' }), + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 100' }), + ] + + const result = resolveFirstMatchScaTransactionData( + scaMetadataBothVersions, + 'sca_card', + transactionData, + 'en', + makeConfig() + ) + assert.ok(result, 'should resolve') + assert.strictEqual(result.index, 0) + assert.strictEqual(result.entry.type, 'urn:eudi:sca:com.example.pay:transaction:2') + }) + + it('falls back to older version when newer does not match', () => { + // v2 entry at index 0, v1 at index 1. Credential only supports v1. + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:2', ['sca_card'], { amount: 'EUR 100' }), + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 100' }), + ] + + const result = resolveFirstMatchScaTransactionData( + scaMetadataV1Only, + 'sca_card', + transactionData, + 'en', + makeConfig() + ) + assert.ok(result, 'should resolve') + assert.strictEqual(result.index, 1) + assert.strictEqual(result.entry.type, 'urn:eudi:sca:com.example.pay:transaction:1') + }) + + it('only entries with matching credential_ids are considered', () => { + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['other_credential'], { amount: 'EUR 50' }), + ] + + const result = resolveFirstMatchScaTransactionData( + scaMetadataBothVersions, + 'sca_card', + transactionData, + 'en', + makeConfig() + ) + assert.strictEqual(result, undefined) + }) + + it('returns undefined when no entry matches', () => { + // Credential supports v1 only, but only v2 entries are provided + const scaMetadataV2Only: ScaCredentialMetadata = { + transaction_data_types: { + 'urn:eudi:sca:com.example.pay:transaction:2': { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Amount v2' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirm v2' }], + }, + }, + }, + } + + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 100' }), + ] + + const result = resolveFirstMatchScaTransactionData( + scaMetadataV2Only, + 'sca_card', + transactionData, + 'en', + makeConfig() + ) + assert.strictEqual(result, undefined) + }) +}) + +// ============================================================================= +// Section 3.3 — Credential exclusion (resolveAllMatchedCredentials) +// ============================================================================= + +describe('Section 3.3 — Credential exclusion (resolveAllMatchedCredentials)', () => { + it('excludes credentials that fail transaction data resolution when targeted', () => { + const scaCred = makeScaCredential('c_sca', ['urn:eudi:sca:com.example.pay:transaction:1']) + const plainCred = makeCredential('c_plain') + + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['q1'], { amount: 'EUR 50' }), + ] + + const resolved = resolveAllMatchedCredentials( + [scaCred, plainCred], + 'q1', + undefined, + transactionData, + 'en', + makeConfig() + ) + + // scaCred has matching scaMetadata so it resolves. plainCred has no scaMetadata + // and no checkNonScaTransactionDataSupport, so its transactionData is undefined => filtered out. + assert.strictEqual(resolved.length, 1) + assert.strictEqual(resolved[0].credentialId, 'c_sca') + assert.ok(resolved[0].transactionData, 'should have transactionData') + }) + + it('does not filter non-targeted credentials', () => { + const cred1 = makeCredential('c1') + const cred2 = makeCredential('c2') + + // Transaction data targets a different query ID + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['other_query'], { amount: 'EUR 50' }), + ] + + const resolved = resolveAllMatchedCredentials([cred1, cred2], 'q1', undefined, transactionData, 'en', makeConfig()) + + assert.strictEqual(resolved.length, 2) + }) + + it('returns all credentials when there is no transaction data', () => { + const cred1 = makeCredential('c1') + const cred2 = makeCredential('c2') + + const resolved = resolveAllMatchedCredentials([cred1, cred2], 'q1', undefined, [], 'en', makeConfig()) + + assert.strictEqual(resolved.length, 2) + }) +}) + +// ============================================================================= +// Section 3.3 — Non-SCA transaction data (findFirstNonScaTransactionData) +// ============================================================================= + +describe('Section 3.3 — Non-SCA transaction data (findFirstNonScaTransactionData)', () => { + it('uses checkNonScaTransactionDataSupport callback', () => { + const config = makeConfig({ + checkNonScaTransactionDataSupport: (credentialId, type) => credentialId === 'c1' && type === 'custom_type', + }) + const transactionData = [makeTransactionData('custom_type', ['q1'], { data: 'x' })] + const result = findFirstNonScaTransactionData('c1', 'q1', transactionData, config) + assert.ok(result) + assert.strictEqual(result.index, 0) + assert.strictEqual(result.entry.type, 'custom_type') + }) + + it('returns undefined when callback is absent', () => { + const config = makeConfig() + const transactionData = [makeTransactionData('custom_type', ['q1'], { data: 'x' })] + const result = findFirstNonScaTransactionData('c1', 'q1', transactionData, config) + assert.strictEqual(result, undefined) + }) + + it('skips SCA-typed entries', () => { + const config = makeConfig({ + checkNonScaTransactionDataSupport: () => true, + }) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['q1'], { amount: '10' }), + makeTransactionData('custom_type', ['q1'], { data: 'x' }), + ] + const result = findFirstNonScaTransactionData('c1', 'q1', transactionData, config) + assert.ok(result) + assert.strictEqual(result.index, 1) + assert.strictEqual(result.entry.type, 'custom_type') + }) + + it('returns undefined when callback rejects the type', () => { + const config = makeConfig({ + checkNonScaTransactionDataSupport: () => false, + }) + const transactionData = [makeTransactionData('custom_type', ['q1'], { data: 'x' })] + const result = findFirstNonScaTransactionData('c1', 'q1', transactionData, config) + assert.strictEqual(result, undefined) + }) + + it('returns undefined when credential query is not targeted', () => { + const config = makeConfig({ + checkNonScaTransactionDataSupport: () => true, + }) + const transactionData = [makeTransactionData('custom_type', ['other_query'], { data: 'x' })] + const result = findFirstNonScaTransactionData('c1', 'q1', transactionData, config) + assert.strictEqual(result, undefined) + }) +}) + +// ============================================================================= +// Section 3.4 — Transposability +// ============================================================================= + +describe('Section 3.4 — Transposability', () => { + describe('decomposeTransposable', () => { + it('decomposes transposable options into independent slots', () => { + // Spec example: [["sca_card","pid_1"], ["sca_card","pid_2"], ["sca_card"]] + const alternatives = [['sca_card', 'pid_1'], ['sca_card', 'pid_2'], ['sca_card']] + const result = decomposeTransposable(alternatives) + assert.ok(result, 'should be transposable') + assert.strictEqual(result.length, 2) + + const scaSlot = result.find((s) => s.ids.includes('sca_card')) + assert.ok(scaSlot) + assert.strictEqual(scaSlot.optional, false) + + const pidSlot = result.find((s) => s.ids.includes('pid_1')) + assert.ok(pidSlot) + assert.ok(pidSlot.ids.includes('pid_2')) + assert.strictEqual(pidSlot.optional, true) + }) + + it('returns undefined for non-transposable options', () => { + // Spec counterexample: pid_2 and loyalty co-occur in one option but not in all + const alternatives = [['sca_card', 'pid_1'], ['sca_card', 'pid_2', 'loyalty'], ['sca_card']] + const result = decomposeTransposable(alternatives) + assert.strictEqual(result, undefined) + }) + + it('detects optional slot via empty-set presence', () => { + // ["sca_card"] means the pid slot is absent => optional + const alternatives = [['sca_card', 'pid'], ['sca_card']] + const result = decomposeTransposable(alternatives) + assert.ok(result) + assert.strictEqual(result.length, 2) + + const pidSlot = result.find((s) => s.ids.includes('pid')) + assert.ok(pidSlot) + assert.strictEqual(pidSlot.optional, true) + + const scaSlot = result.find((s) => s.ids.includes('sca_card')) + assert.ok(scaSlot) + assert.strictEqual(scaSlot.optional, false) + }) + + it('handles multiple SCA alternatives in the same slot', () => { + // sca_card and sca_account never co-occur => same slot + const alternatives = [['sca_card', 'pid'], ['sca_card'], ['sca_account', 'pid'], ['sca_account']] + const result = decomposeTransposable(alternatives) + assert.ok(result, 'should be transposable') + assert.strictEqual(result.length, 2) + + const scaSlot = result.find((s) => s.ids.includes('sca_card')) + assert.ok(scaSlot) + assert.ok(scaSlot.ids.includes('sca_account')) + }) + + it('returns empty array for empty input', () => { + const result = decomposeTransposable([]) + assert.ok(result) + assert.strictEqual(result.length, 0) + }) + + it('decomposes corrected 6-alternative example into 3 slots', () => { + const alternatives = [ + ['sca_card', 'pid_1', 'loyalty'], + ['sca_card', 'pid_2', 'loyalty'], + ['sca_card', 'pid_1'], + ['sca_card', 'pid_2'], + ['sca_card', 'loyalty'], + ['sca_card'], + ] + const result = decomposeTransposable(alternatives) + assert.ok(result, 'should be transposable') + assert.strictEqual(result.length, 3) + + const scaSlot = result.find((s) => s.ids.includes('sca_card')) + assert.ok(scaSlot) + assert.strictEqual(scaSlot.optional, false) + + const pidSlot = result.find((s) => s.ids.includes('pid_1')) + assert.ok(pidSlot) + assert.ok(pidSlot.ids.includes('pid_2')) + assert.strictEqual(pidSlot.optional, true) + + const loyaltySlot = result.find((s) => s.ids.includes('loyalty')) + assert.ok(loyaltySlot) + assert.strictEqual(loyaltySlot.optional, true) + }) + }) + + describe('verifyCartesianProduct', () => { + it('returns true when alternatives equal the product', () => { + const slots: SlotDecomposition[] = [ + { ids: ['a', 'b'], optional: false }, + { ids: ['x'], optional: false }, + ] + const alternatives = [ + ['a', 'x'], + ['b', 'x'], + ] + assert.strictEqual(verifyCartesianProduct(slots, alternatives), true) + }) + + it('returns false on size mismatch', () => { + const slots: SlotDecomposition[] = [ + { ids: ['a', 'b'], optional: false }, + { ids: ['x'], optional: false }, + ] + const alternatives = [['a', 'x']] + assert.strictEqual(verifyCartesianProduct(slots, alternatives), false) + }) + + it('returns false when an alternative is not in the product', () => { + const slots: SlotDecomposition[] = [ + { ids: ['a', 'b'], optional: false }, + { ids: ['x'], optional: false }, + ] + const alternatives = [ + ['a', 'x'], + ['a', 'y'], + ] + assert.strictEqual(verifyCartesianProduct(slots, alternatives), false) + }) + + it('verifies product with optional slots including the empty option', () => { + const slots: SlotDecomposition[] = [ + { ids: ['a'], optional: false }, + { ids: ['x', 'y'], optional: true }, + ] + const alternatives = [['a', 'x'], ['a', 'y'], ['a']] + assert.strictEqual(verifyCartesianProduct(slots, alternatives), true) + }) + }) + + describe('generateCartesianProduct', () => { + it('generates product with optional slots', () => { + const slots: SlotDecomposition[] = [ + { ids: ['a'], optional: false }, + { ids: ['x', 'y'], optional: true }, + ] + const product = generateCartesianProduct(slots) + assert.strictEqual(product.length, 3) + const sorted = product.map((a) => [...a].sort()).sort() + const expected = [['a'], ['a', 'x'], ['a', 'y']].map((a) => [...a].sort()).sort() + assert.deepStrictEqual(sorted, expected) + }) + + it('returns [[]] for empty slots', () => { + assert.deepStrictEqual(generateCartesianProduct([]), [[]]) + }) + }) + + describe('bestEffortDecompose', () => { + it('returns exact decomposition when transposable', () => { + const alternatives = [ + ['a', 'x'], + ['b', 'x'], + ] + const result = bestEffortDecompose(alternatives) + assert.strictEqual(result.length, 2) + }) + + it('always succeeds even for non-transposable input', () => { + const alternatives = [['sca_card', 'pid_1'], ['sca_card', 'pid_2', 'loyalty'], ['sca_card']] + const result = bestEffortDecompose(alternatives) + assert.ok(result.length > 0, 'should return at least one slot') + const allIds = result.flatMap((s) => s.ids) + assert.ok(allIds.includes('sca_card')) + assert.ok(allIds.includes('pid_1')) + }) + + it('returns empty for empty input', () => { + assert.deepStrictEqual(bestEffortDecompose([]), []) + }) + }) + + describe('buildCoOccurrences', () => { + it('detects pairs that co-occur', () => { + const pairs = buildCoOccurrences([ + ['a', 'b'], + ['a', 'c'], + ]) + assert.strictEqual(pairs.has('a\0b'), true) + assert.strictEqual(pairs.has('a\0c'), true) + assert.strictEqual(pairs.has('b\0c'), false) + }) + + it('normalizes pair keys min\\0max', () => { + const pairs = buildCoOccurrences([['z', 'a']]) + assert.strictEqual(pairs.has('a\0z'), true) + assert.strictEqual(pairs.has('z\0a'), false) + }) + }) +}) + +// ============================================================================= +// Section 3.4 — Credential set resolution +// ============================================================================= + +describe('Section 3.4 — Credential set resolution', () => { + describe('resolveScaCredentialSet', () => { + it('SCA options MUST be transposable — returns Err if not', () => { + const queries = buildQueryMap([ + makeQuery('sca_card'), + makeQuery('pid_1'), + makeQuery('pid_2'), + makeQuery('loyalty'), + ]) + const matcher = makeMatcher({ + sca_card: [makeCredential('c1')], + pid_1: [makeCredential('c2')], + pid_2: [makeCredential('c3')], + loyalty: [makeCredential('c4')], + }) + const credentialSet: DcqlCredentialSetQuery = { + options: [['sca_card', 'pid_1'], ['sca_card', 'pid_2', 'loyalty'], ['sca_card']], + } + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 100' }), + ] + const result = resolveScaCredentialSet(credentialSet, queries, transactionData, matcher, 'en', makeConfig()) + assert.strictEqual(isErr(result), true) + if (isErr(result)) { + assert.ok(result.error.includes('not transposable')) + } + }) + + it('resolves transposable SCA options into slots', () => { + const queries = buildQueryMap([makeQuery('sca_card'), makeQuery('pid')]) + const scaCred = makeScaCredential('c_sca', ['urn:eudi:sca:com.example.pay:transaction:1']) + const pidCred = makeCredential('c_pid') + const matcher = makeMatcher({ + sca_card: [scaCred], + pid: [pidCred], + }) + const credentialSet: DcqlCredentialSetQuery = { + options: [['sca_card', 'pid'], ['sca_card']], + } + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 50' }), + ] + const result = resolveScaCredentialSet(credentialSet, queries, transactionData, matcher, 'en', makeConfig()) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + assert.ok(result.value.slots.length > 0) + assert.strictEqual(result.value.required, true) + } + }) + }) + + describe('resolveNonScaCredentialSet', () => { + it('uses best-effort — never errors', () => { + const queries = buildQueryMap([makeQuery('a'), makeQuery('b'), makeQuery('c')]) + const matcher = makeMatcher({ + a: [makeCredential('c1')], + b: [makeCredential('c2')], + c: [makeCredential('c3')], + }) + const credentialSet: DcqlCredentialSetQuery = { + options: [['a', 'b'], ['a', 'b', 'c'], ['a']], + } + // resolveNonScaCredentialSet returns ResolvedCredentialSet directly, not a Result + const result = resolveNonScaCredentialSet(credentialSet, queries, [], matcher, 'en', makeConfig()) + assert.ok(result) + assert.ok(result.slots.length > 0) + }) + + it('preserves description and required fields', () => { + const queries = buildQueryMap([makeQuery('a')]) + const matcher = makeMatcher({ a: [makeCredential('c1')] }) + const credentialSet: DcqlCredentialSetQuery = { + options: [['a']], + description: 'Test set', + required: false, + } + const result = resolveNonScaCredentialSet(credentialSet, queries, [], matcher, 'en', makeConfig()) + assert.strictEqual(result.description, 'Test set') + assert.strictEqual(result.required, false) + }) + }) + + describe('partitionOptions', () => { + it('splits SCA vs non-SCA options', () => { + const options = [['sca_card', 'pid'], ['other_cred'], ['sca_card']] + const scaIds = new Set(['sca_card']) + const { sca, nonSca } = partitionOptions(options, scaIds) + assert.deepStrictEqual(sca, [['sca_card', 'pid'], ['sca_card']]) + assert.deepStrictEqual(nonSca, [['other_cred']]) + }) + + it('returns all as nonSca when no SCA ids', () => { + const options = [['a', 'b'], ['c']] + const { sca, nonSca } = partitionOptions(options, new Set()) + assert.strictEqual(sca.length, 0) + assert.strictEqual(nonSca.length, 2) + }) + + it('returns all as sca when every option contains an SCA id', () => { + const options = [['sca_card', 'pid'], ['sca_card']] + const { sca, nonSca } = partitionOptions(options, new Set(['sca_card'])) + assert.strictEqual(sca.length, 2) + assert.strictEqual(nonSca.length, 0) + }) + }) + + describe('orderSlotsByReference', () => { + it('orders slots by first-appearance position in reference option', () => { + const slots: SlotDecomposition[] = [ + { ids: ['c'], optional: false }, + { ids: ['a'], optional: false }, + { ids: ['b'], optional: false }, + ] + const reference = ['a', 'b', 'c'] + const ordered = orderSlotsByReference(slots, reference) + assert.deepStrictEqual( + ordered.map((s) => s.ids), + [['a'], ['b'], ['c']] + ) + }) + + it('preserves order when ids overlap with reference positions', () => { + const slots: SlotDecomposition[] = [ + { ids: ['x', 'y'], optional: true }, + { ids: ['a'], optional: false }, + ] + const reference = ['a', 'x'] + const ordered = orderSlotsByReference(slots, reference) + assert.deepStrictEqual( + ordered.map((s) => s.ids), + [['a'], ['x', 'y']] + ) + }) + }) + + describe('collectScaCredentialQueryIds', () => { + it('collects IDs from SCA entries only', () => { + const td = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card']), + makeTransactionData('other_type', ['other_cred']), + makeTransactionData('urn:eudi:sca:eu.europa.ec:transfer:single:1', ['sca_account', 'pid']), + ] + const ids = collectScaCredentialQueryIds(td, makeConfig()) + assert.deepStrictEqual([...ids].sort(), ['pid', 'sca_account', 'sca_card']) + }) + + it('returns empty set when no SCA entries', () => { + const td = [makeTransactionData('custom_type', ['cred1'])] + const ids = collectScaCredentialQueryIds(td, makeConfig()) + assert.strictEqual(ids.size, 0) + }) + }) + + describe('isOptionSatisfiable', () => { + it('returns true when all queries have matches', () => { + const queries = buildQueryMap([makeQuery('a'), makeQuery('b')]) + const matcher = makeMatcher({ + a: [makeCredential('c1')], + b: [makeCredential('c2')], + }) + assert.strictEqual(isOptionSatisfiable(['a', 'b'], queries, matcher), true) + }) + + it('returns false when some queries have no matches', () => { + const queries = buildQueryMap([makeQuery('a'), makeQuery('b')]) + const matcher = makeMatcher({ a: [makeCredential('c1')], b: [] }) + assert.strictEqual(isOptionSatisfiable(['a', 'b'], queries, matcher), false) + }) + + it('returns false when query is missing from map', () => { + const queries = buildQueryMap([makeQuery('a')]) + const matcher = makeMatcher({ a: [makeCredential('c1')] }) + assert.strictEqual(isOptionSatisfiable(['a', 'missing'], queries, matcher), false) + }) + }) + + describe('findFirstSatisfiableOption', () => { + it('returns the first satisfiable option', () => { + const queries = buildQueryMap([makeQuery('a'), makeQuery('b'), makeQuery('c')]) + const matcher = makeMatcher({ + a: [], + b: [makeCredential('c2')], + c: [makeCredential('c3')], + }) + const options = [['a'], ['b'], ['c']] + const result = findFirstSatisfiableOption(options, queries, matcher) + assert.deepStrictEqual(result, ['b']) + }) + + it('returns undefined when none satisfiable', () => { + const queries = buildQueryMap([makeQuery('a')]) + const matcher = makeMatcher({ a: [] }) + assert.strictEqual(findFirstSatisfiableOption([['a']], queries, matcher), undefined) + }) + }) +}) + +// ============================================================================= +// Section 3.4 — DCQL validation (validateTransactionDataCredentialSet) +// ============================================================================= + +describe('Section 3.4 — DCQL validation (validateTransactionDataCredentialSet)', () => { + it('all SCA credential_ids must appear in the same credential set', () => { + const dcqlQuery = { + credentials: [makeQuery('a'), makeQuery('b')], + credential_sets: [{ options: [['a']] }, { options: [['b']] }], + } + const td = [makeTransactionData('some_type', ['a', 'b'])] + const error = validateTransactionDataCredentialSet(dcqlQuery, td) + assert.ok(error, 'should return an error string') + assert.ok(error.includes('same credential set')) + }) + + it('passes when no transaction_data', () => { + const dcqlQuery = { + credentials: [makeQuery('a')], + credential_sets: [{ options: [['a']] }], + } + assert.strictEqual(validateTransactionDataCredentialSet(dcqlQuery, []), undefined) + }) + + it('passes when no credential_sets', () => { + const dcqlQuery = { credentials: [makeQuery('a')] } + const td = [makeTransactionData('some_type', ['a'])] + assert.strictEqual(validateTransactionDataCredentialSet(dcqlQuery, td), undefined) + }) + + it('passes when all credential IDs are in the same set', () => { + const dcqlQuery = { + credentials: [makeQuery('a'), makeQuery('b')], + credential_sets: [{ options: [['a', 'b'], ['a']] }], + } + const td = [makeTransactionData('some_type', ['a', 'b'])] + assert.strictEqual(validateTransactionDataCredentialSet(dcqlQuery, td), undefined) + }) + + it('passes with empty credential_sets array', () => { + const dcqlQuery = { + credentials: [makeQuery('a')], + credential_sets: [], + } + const td = [makeTransactionData('some_type', ['a'])] + assert.strictEqual(validateTransactionDataCredentialSet(dcqlQuery, td), undefined) + }) +}) + +// ============================================================================= +// Section 3.5.4 — Locale selection in SCA path (canResolveCredentialForLocale) +// ============================================================================= + +describe('Section 3.5.4 — Locale selection in SCA path', () => { + it('locale must satisfy ALL display arrays (canResolveCredentialForLocale)', () => { + // Credential with display that only has a French entry + const cred: MatchedCredential = { + credentialId: 'c1', + display: [{ name: 'Carte', locale: 'fr' }], + } + // Trying to resolve for 'en' should fail because the display has no matching locale + const result = canResolveCredentialForLocale(cred, 'q1', [], 'en', makeConfig()) + assert.strictEqual(result, false) + }) + + it('locale succeeds when display has a matching entry', () => { + const cred: MatchedCredential = { + credentialId: 'c1', + display: [ + { name: 'Card', locale: 'en' }, + { name: 'Carte', locale: 'fr' }, + ], + } + const result = canResolveCredentialForLocale(cred, 'q1', [], 'en', makeConfig()) + assert.strictEqual(result, true) + }) + + it('locale succeeds when display has a default entry (no locale)', () => { + const cred: MatchedCredential = { + credentialId: 'c1', + display: [{ name: 'Card' }], + } + const result = canResolveCredentialForLocale(cred, 'q1', [], 'de', makeConfig()) + assert.strictEqual(result, true) + }) + + it('locale succeeds when credential has no display at all', () => { + const cred: MatchedCredential = { credentialId: 'c1' } + const result = canResolveCredentialForLocale(cred, 'q1', [], 'en', makeConfig()) + assert.strictEqual(result, true) + }) + + it('SCA transaction display must also resolve for the locale', () => { + const cred = makeScaCredential('c1', ['urn:eudi:sca:com.example.pay:transaction:1']) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['q1'], { amount: 'EUR 50' }), + ] + // Default config with 'en' locale. SCA metadata claims have display with no locale + // (default entry), so it should resolve for 'en'. + const result = canResolveCredentialForLocale(cred, 'q1', transactionData, 'en', makeConfig()) + assert.strictEqual(result, true) + }) + + it('priority list: first complete match wins in resolveDcql', () => { + // Credential display only has 'fr' entry. + // Config has locales: ['en', 'fr']. + // 'en' should fail, 'fr' should succeed. + const queries = [makeQuery('sca_card')] + const scaCred: MatchedCredential = { + credentialId: 'c_sca', + display: [{ name: 'Carte SCA', locale: 'fr' }], + scaMetadata: { + transaction_data_types: { + 'urn:eudi:sca:com.example.pay:transaction:1': { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Montant', locale: 'fr' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'Confirmer', locale: 'fr' }], + }, + }, + }, + } as ScaCredentialMetadata, + } + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['sca_card']] }], + } + const matcher = makeMatcher({ sca_card: [scaCred] }) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 50' }), + ] + const config = makeConfig({ locales: ['en', 'fr'] }) + const result = resolveDcql(dcqlQuery, transactionData, matcher, config) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + assert.strictEqual(result.value.locale, 'fr') + } + }) + + it('priority list exhausted returns Err', () => { + // Credential display only has 'ja' entry. + // Config has locales: ['en', 'fr']. Neither matches. + const queries = [makeQuery('sca_card')] + const scaCred: MatchedCredential = { + credentialId: 'c_sca', + display: [{ name: 'SCA Card', locale: 'ja' }], + scaMetadata: { + transaction_data_types: { + 'urn:eudi:sca:com.example.pay:transaction:1': { + claims: [ + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ name: 'Amount', locale: 'ja' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'OK', locale: 'ja' }], + }, + }, + }, + } as ScaCredentialMetadata, + } + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['sca_card']] }], + } + const matcher = makeMatcher({ sca_card: [scaCred] }) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 50' }), + ] + const config = makeConfig({ locales: ['en', 'fr'] }) + const result = resolveDcql(dcqlQuery, transactionData, matcher, config) + assert.strictEqual(isErr(result), true) + if (isErr(result)) { + assert.ok(result.error.includes('locale')) + } + }) +}) + +// ============================================================================= +// SCA/non-SCA path detection (hasScaTransactionData, resolveDcql) +// ============================================================================= + +describe('SCA/non-SCA path detection', () => { + describe('hasScaTransactionData', () => { + it('returns true when any transaction_data entry is SCA-typed', () => { + assert.strictEqual( + hasScaTransactionData( + [makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['c1'])], + makeConfig() + ), + true + ) + }) + + it('returns true when SCA mixed with non-SCA', () => { + assert.strictEqual( + hasScaTransactionData( + [ + makeTransactionData('custom_type', ['c1']), + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['c2']), + ], + makeConfig() + ), + true + ) + }) + + it('returns false when none are SCA', () => { + assert.strictEqual(hasScaTransactionData([makeTransactionData('custom_type', ['c1'])], makeConfig()), false) + }) + + it('returns false for empty array', () => { + assert.strictEqual(hasScaTransactionData([], makeConfig()), false) + }) + }) + + describe('resolveDcql — SCA path', () => { + it('takes SCA path when any transaction_data entry is SCA-typed', () => { + const queries = [makeQuery('sca_card'), makeQuery('pid')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['sca_card', 'pid'], ['sca_card']] }], + } + + const scaCred = makeScaCredential('c_sca', ['urn:eudi:sca:com.example.pay:transaction:1']) + const pidCred = makeCredential('c_pid') + const matcher = makeMatcher({ + sca_card: [scaCred], + pid: [pidCred], + }) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 50' }), + ] + + const result = resolveDcql(dcqlQuery, transactionData, matcher, makeConfig()) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + assert.strictEqual(result.value.locale, 'en') + assert.strictEqual(result.value.credentialSets.length, 1) + assert.ok(result.value.credentialSets[0].slots.length > 0) + } + }) + + it('SCA path returns Err for non-transposable options', () => { + const queries = [makeQuery('sca_card'), makeQuery('pid_1'), makeQuery('pid_2'), makeQuery('loyalty')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['sca_card', 'pid_1'], ['sca_card', 'pid_2', 'loyalty'], ['sca_card']] }], + } + + const matcher = makeMatcher({ + sca_card: [makeScaCredential('c_sca', ['urn:eudi:sca:com.example.pay:transaction:1'])], + pid_1: [makeCredential('c_pid1')], + pid_2: [makeCredential('c_pid2')], + loyalty: [makeCredential('c_loy')], + }) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 50' }), + ] + + const result = resolveDcql(dcqlQuery, transactionData, matcher, makeConfig()) + assert.strictEqual(isErr(result), true) + }) + }) + + describe('resolveDcql — Non-SCA path', () => { + it('takes non-SCA path when none are SCA', () => { + const queries = [makeQuery('a'), makeQuery('b'), makeQuery('c')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['a', 'b'], ['a', 'c'], ['a']] }], + } + + const matcher = makeMatcher({ + a: [makeCredential('c1')], + b: [makeCredential('c2')], + c: [makeCredential('c3')], + }) + + const result = resolveDcql(dcqlQuery, [], matcher, makeConfig()) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + assert.strictEqual(result.value.locale, 'en') + assert.strictEqual(result.value.credentialSets.length, 1) + } + }) + + it('non-SCA path selects first locale, never errors for decomposition', () => { + const queries = [makeQuery('a'), makeQuery('b'), makeQuery('c')] + const dcqlQuery = { + credentials: queries, + // non-transposable options — would fail in SCA path but not here + credential_sets: [{ options: [['a', 'b'], ['a', 'b', 'c'], ['a']] }], + } + + const matcher = makeMatcher({ + a: [makeCredential('c1')], + b: [makeCredential('c2')], + c: [makeCredential('c3')], + }) + + const config = makeConfig({ locales: ['de', 'fr'] }) + const result = resolveDcql(dcqlQuery, [], matcher, config) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + // Non-SCA always picks the first locale from the priority list + assert.strictEqual(result.value.locale, 'de') + } + }) + + it('non-SCA path with non-SCA transaction data', () => { + const queries = [makeQuery('a')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['a']] }], + } + + const matcher = makeMatcher({ + a: [makeCredential('c1')], + }) + const transactionData = [makeTransactionData('custom_type', ['a'], { data: 'test' })] + + const config = makeConfig({ + checkNonScaTransactionDataSupport: () => true, + }) + const result = resolveDcql(dcqlQuery, transactionData, matcher, config) + assert.strictEqual(isOk(result), true) + }) + }) +}) + +// ============================================================================= +// Output structure +// ============================================================================= + +describe('Output structure', () => { + it('SCA resolved credential includes transactionData.resolved', () => { + const queries = [makeQuery('sca_card')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['sca_card']] }], + } + + const scaCred = makeScaCredential('c_sca', ['urn:eudi:sca:com.example.pay:transaction:1']) + const matcher = makeMatcher({ sca_card: [scaCred] }) + const transactionData = [ + makeTransactionData('urn:eudi:sca:com.example.pay:transaction:1', ['sca_card'], { amount: 'EUR 50' }), + ] + + const result = resolveDcql(dcqlQuery, transactionData, matcher, makeConfig()) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + const sets = result.value.credentialSets + assert.strictEqual(sets.length, 1) + const slot = sets[0].slots[0] + const alt = slot.alternatives[0] + const cred = alt.credentials[0] + assert.ok(cred.transactionData, 'should have transactionData') + assert.ok(cred.transactionData.resolved, 'SCA credential should have resolved transaction display') + assert.strictEqual(cred.transactionData.entry.type, 'urn:eudi:sca:com.example.pay:transaction:1') + } + }) + + it('non-SCA credential includes raw entry without resolved', () => { + const queries = [makeQuery('a')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['a']] }], + } + + const matcher = makeMatcher({ a: [makeCredential('c1')] }) + const transactionData = [makeTransactionData('custom_type', ['a'], { data: 'test' })] + + const config = makeConfig({ + checkNonScaTransactionDataSupport: () => true, + }) + const result = resolveDcql(dcqlQuery, transactionData, matcher, config) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + const sets = result.value.credentialSets + assert.strictEqual(sets.length, 1) + const cred = sets[0].slots[0].alternatives[0].credentials[0] + assert.ok(cred.transactionData, 'should have transactionData') + assert.strictEqual(cred.transactionData.resolved, undefined, 'non-SCA should not have resolved display') + assert.strictEqual(cred.transactionData.entry.type, 'custom_type') + } + }) + + it('absent credential_sets requests all credentials (OID4VP §6.4.2)', () => { + const result = resolveDcql( + { credentials: [makeQuery('a')] }, + [], + makeMatcher({ a: [makeCredential('c1')] }), + makeConfig() + ) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + assert.strictEqual(result.value.credentialSets.length, 1) + assert.strictEqual(result.value.locale, 'en') + } + }) + + it('result includes selected locale', () => { + const result = resolveDcql( + { credentials: [makeQuery('a')], credential_sets: [{ options: [['a']] }] }, + [], + makeMatcher({ a: [makeCredential('c1')] }), + makeConfig({ locales: ['fr', 'en'] }) + ) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + assert.strictEqual(typeof result.value.locale, 'string') + } + }) + + it('resolved credential carries credentialQueryId', () => { + const queries = [makeQuery('my_query')] + const dcqlQuery = { + credentials: queries, + credential_sets: [{ options: [['my_query']] }], + } + const matcher = makeMatcher({ my_query: [makeCredential('c1')] }) + const result = resolveDcql(dcqlQuery, [], matcher, makeConfig()) + assert.strictEqual(isOk(result), true) + if (isOk(result)) { + const cred = result.value.credentialSets[0].slots[0].alternatives[0].credentials[0] + assert.strictEqual(cred.credentialQueryId, 'my_query') + assert.strictEqual(cred.credentialId, 'c1') + } + }) +}) + +// ============================================================================= +// isTargetedByTransactionData +// ============================================================================= + +describe('isTargetedByTransactionData', () => { + it('returns true when credential query is targeted', () => { + const td = [makeTransactionData('type_a', ['cred_1', 'cred_2'])] + assert.strictEqual(isTargetedByTransactionData('cred_1', td), true) + }) + + it('returns false when credential query is not targeted', () => { + const td = [makeTransactionData('type_a', ['cred_1'])] + assert.strictEqual(isTargetedByTransactionData('cred_2', td), false) + }) + + it('returns false for empty transaction data', () => { + assert.strictEqual(isTargetedByTransactionData('any', []), false) + }) +}) + +// ============================================================================= +// buildCredentialQueryMap +// ============================================================================= + +describe('buildCredentialQueryMap', () => { + it('builds a lookup map from credential query ID to query', () => { + const queries = [makeQuery('a'), makeQuery('b')] + const map = buildCredentialQueryMap(queries) + assert.ok(map) + assert.strictEqual(map.size, 2) + assert.strictEqual(map.get('a')?.id, 'a') + assert.strictEqual(map.get('b')?.id, 'b') + assert.strictEqual(map.get('c'), undefined) + }) + + it('returns undefined for duplicate ids (OID4VP §6.1)', () => { + const map = buildCredentialQueryMap([makeQuery('a'), makeQuery('a')]) + assert.strictEqual(map, undefined) + }) +}) + +// ============================================================================= +// OID4VP Section 6.4.2 — credential_sets absent means "request all credentials" +// ============================================================================= +// "If credential_sets is not provided, the Verifier requests presentations for +// all Credentials in credentials to be returned." + +describe('OID4VP §6.4.2 — credential_sets absent requests all credentials', () => { + it('absent credential_sets resolves each credential query as a required slot', () => { + const dcqlQuery = { credentials: [makeQuery('a'), makeQuery('b')] } + const matcher = makeMatcher({ a: [makeCredential('c1')], b: [makeCredential('c2')] }) + const result = resolveDcql(dcqlQuery, [], matcher, makeConfig()) + assert.ok(isOk(result)) + // Per OID4VP §6.4.2: all credentials should be requested + assert.strictEqual(result.value.credentialSets.length, 2) + assert.ok(result.value.credentialSets.every((cs) => cs.required === true)) + }) +}) + +// ============================================================================= +// OID4VP Section 6.1 — Duplicate credential query id +// ============================================================================= +// "Within the Authorization Request, the same id MUST NOT be present more than once." + +// ============================================================================= +// OID4VP Section 6.1, 6.4.1 — validateDcqlQueryStructure +// ============================================================================= + +describe('OID4VP §6.1, §6.4.1 — validateDcqlQueryStructure', () => { + it('returns error for duplicate credential query ids (§6.1)', () => { + const error = validateDcqlQueryStructure({ credentials: [makeQuery('a'), makeQuery('a')] }) + assert.ok(error) + assert.ok(error.includes('Duplicate')) + }) + + it('returns error for claim_sets without claims (§6.4.1)', () => { + const query: DcqlCredentialQuery = { + id: 'a', + format: 'dc+sd-jwt', + meta: { vct_values: ['x'] }, + claim_sets: [['c1']], + } + const error = validateDcqlQueryStructure({ credentials: [query] }) + assert.ok(error) + assert.ok(error.includes('claim_sets')) + }) + + it('returns undefined for valid query', () => { + assert.strictEqual(validateDcqlQueryStructure({ credentials: [makeQuery('a'), makeQuery('b')] }), undefined) + }) + + it('resolveDcql returns Err for duplicate ids', () => { + const result = resolveDcql({ credentials: [makeQuery('a'), makeQuery('a')] }, [], makeMatcher({}), makeConfig()) + assert.ok(isErr(result)) + }) + + it('resolveDcql returns Err for claim_sets without claims', () => { + const query: DcqlCredentialQuery = { + id: 'a', + format: 'dc+sd-jwt', + meta: { vct_values: ['x'] }, + claim_sets: [['c1']], + } + const result = resolveDcql({ credentials: [query] }, [], makeMatcher({}), makeConfig()) + assert.ok(isErr(result)) + }) +}) diff --git a/tests/resolver.test.ts b/tests/resolver.test.ts new file mode 100644 index 0000000..c61ee03 --- /dev/null +++ b/tests/resolver.test.ts @@ -0,0 +1,1005 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import type { + ClaimMetadata, + ScaCredentialMetadata, + ScaTransactionDataEntry, + TransactionDataType, + UiLabelEntry, +} from '@animo-id/eudi-wallet-ts12-validation' +import type { ValueTypeResolvers } from '../packages/resolver/src/index' +import { + expandClaims, + filterSupportedDisplayEntries, + getPayloadValue, + interpolatePlaceholders, + lookupLocale, + resolveAllClaims, + resolveAllUiLabels, + resolveDisplayableClaim, + resolveTransactionDisplay, + resolveTypedValue, + resolveUiLabel, + selectLocaleEntry, + validateMandatoryClaims, + validateNoUndeclaredPayloadFields, + verifyLocaleSupport, +} from '../packages/resolver/src/index' + +// --------------------------------------------------------------------------- +// Shared resolvers +// --------------------------------------------------------------------------- + +/** Resolvers that echo the raw value back (sufficient for most tests). */ +const echoResolvers: ValueTypeResolvers = { + string: (v: string) => String(v), + iso_currency_amount: (v: string) => String(v), + iso_date_time: (v: string) => String(v), +} + +const emptyResolvers: ValueTypeResolvers = {} + +// --------------------------------------------------------------------------- +// Spec fixture -- TS12 Annex D.8 payment transaction +// --------------------------------------------------------------------------- +const TYPE_KEY = 'urn:eudi:sca:eu.europa.ec:payment:single:1' as const + +const credentialMetadata: ScaCredentialMetadata = { + transaction_data_types: { + [TYPE_KEY]: { + claims: [ + { path: ['transaction_id'], mandatory: true }, + { + path: ['date_time'], + value_type: 'iso_date_time', + display: [ + { locale: 'de-DE', name: 'Datum' }, + { locale: 'en-GB', name: 'Date' }, + ], + }, + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [ + { locale: 'de-DE', name: 'Betrag' }, + { locale: 'en-GB', name: 'Amount' }, + ], + }, + { + path: ['payee', 'name'], + mandatory: true, + display: [ + { locale: 'de-DE', name: 'Empfaenger' }, + { locale: 'en-GB', name: 'Payee' }, + ], + }, + { path: ['payee', 'id'], mandatory: true }, + ], + ui_labels: { + affirmative_action_label: [ + { locale: 'de-DE', value: 'Zahlung bestaetigen' }, + { locale: 'en-GB', value: 'Confirm Payment' }, + ], + denial_action_label: [ + { locale: 'de-DE', value: 'Abbrechen' }, + { locale: 'en-GB', value: 'Cancel' }, + ], + transaction_title: [ + { locale: 'de-DE', value: 'Zahlung an {3}' }, + { locale: 'en-GB', value: 'Payment to {3}' }, + ], + }, + }, + }, +} + +const typeMetadata = credentialMetadata.transaction_data_types[TYPE_KEY] as TransactionDataType + +const fullPayload: Record = { + transaction_id: 'tx-001', + date_time: '2025-12-01T10:00:00Z', + amount: '49.99 EUR', + payee: { name: 'Shop AG', id: 'DE1234' }, +} + +// =========================================================================== +// RFC 4647 Section 3.4 -- Locale Lookup (selectLocaleEntry, lookupLocale) +// =========================================================================== +describe('RFC 4647 Section 3.4 -- Locale Lookup', () => { + describe('lookupLocale', () => { + it('exact match returns the matching tag', () => { + assert.strictEqual(lookupLocale('de-DE', ['de-DE', 'en-GB']), 'de-DE') + }) + + it('case insensitivity: DE-de matches de-DE', () => { + assert.strictEqual(lookupLocale('DE-de', ['de-DE', 'en-GB']), 'de-DE') + }) + + it('subtag truncation: de-CH-1996 falls back to de when de-CH is absent', () => { + assert.strictEqual(lookupLocale('de-CH-1996', ['de', 'en']), 'de') + }) + + it('subtag truncation: de-CH falls back to de', () => { + assert.strictEqual(lookupLocale('de-CH', ['de', 'en']), 'de') + }) + + it('single-char subtags are removed during truncation (en-x-private -> en)', () => { + assert.strictEqual(lookupLocale('en-x-private', ['en', 'de']), 'en') + }) + + it('no match returns undefined', () => { + assert.strictEqual(lookupLocale('fr-FR', ['de-DE', 'en-GB']), undefined) + }) + + it('empty available tags returns undefined', () => { + assert.strictEqual(lookupLocale('en', []), undefined) + }) + }) + + describe('selectLocaleEntry', () => { + it('exact locale match returns the tagged entry', () => { + const entries = [ + { locale: 'de-DE', value: 'Hallo' }, + { locale: 'en-GB', value: 'Hello' }, + ] + const result = selectLocaleEntry(entries, 'en-GB') + assert.deepStrictEqual(result, { locale: 'en-GB', value: 'Hello' }) + }) + + it('subtag truncation: de-CH matches de entry', () => { + const entries = [ + { locale: 'de', value: 'Hallo' }, + { locale: 'en', value: 'Hello' }, + ] + const result = selectLocaleEntry(entries, 'de-CH') + assert.deepStrictEqual(result, { locale: 'de', value: 'Hallo' }) + }) + + it('case insensitivity: EN-gb matches en-GB', () => { + const entries = [ + { locale: 'de-DE', value: 'Hallo' }, + { locale: 'en-GB', value: 'Hello' }, + ] + const result = selectLocaleEntry(entries, 'EN-gb') + assert.deepStrictEqual(result, { locale: 'en-GB', value: 'Hello' }) + }) + + it('first-in-array-order wins when multiple entries match at same truncation step', () => { + const entries = [ + { locale: 'en', value: 'First' }, + { locale: 'en', value: 'Second' }, + ] + const result = selectLocaleEntry(entries, 'en') + assert.deepStrictEqual(result, { locale: 'en', value: 'First' }) + }) + + it('fallback to default entry (no locale field) when no tag matches', () => { + const entries = [ + { locale: 'de-DE', value: 'Hallo' }, + { value: 'Default' }, // no locale = default + ] + const result = selectLocaleEntry(entries, 'fr-FR') + assert.deepStrictEqual(result, { value: 'Default' }) + }) + + it('prefers tagged match over default entry', () => { + const entries = [{ value: 'Default' }, { locale: 'de-DE', value: 'Tagged' }] + const result = selectLocaleEntry(entries, 'de-DE') + assert.deepStrictEqual(result, { locale: 'de-DE', value: 'Tagged' }) + }) + + it('no match and no default returns undefined', () => { + const entries = [ + { locale: 'de-DE', value: 'Hallo' }, + { locale: 'en-GB', value: 'Hello' }, + ] + assert.strictEqual(selectLocaleEntry(entries, 'fr-FR'), undefined) + }) + }) +}) + +// =========================================================================== +// Section 3.5.2 -- Value Resolution (getPayloadValue, resolveTypedValue) +// =========================================================================== +describe('Section 3.5.2 -- Value Resolution', () => { + describe('getPayloadValue', () => { + it('string key navigates into an object', () => { + assert.strictEqual(getPayloadValue(fullPayload, ['amount']), '49.99 EUR') + }) + + it('nested string keys navigate deep objects', () => { + assert.strictEqual(getPayloadValue(fullPayload, ['payee', 'name']), 'Shop AG') + }) + + it('numeric index selects array element', () => { + const payload = { items: ['a', 'b', 'c'] } + assert.strictEqual(getPayloadValue(payload, ['items', 1]), 'b') + }) + + it('negative index returns undefined', () => { + const payload = { items: ['a', 'b', 'c'] } + assert.strictEqual(getPayloadValue(payload, ['items', -1]), undefined) + }) + + it('out-of-bounds index returns undefined', () => { + const payload = { items: ['a'] } + assert.strictEqual(getPayloadValue(payload, ['items', 5]), undefined) + }) + + it('null wildcard maps over array elements', () => { + const payload = { items: [{ n: 'A' }, { n: 'B' }] } + assert.deepStrictEqual(getPayloadValue(payload, ['items', null, 'n']), ['A', 'B']) + }) + + it('null wildcard on non-array returns undefined', () => { + const payload = { notArray: 'hello' } + assert.strictEqual(getPayloadValue(payload, ['notArray', null]), undefined) + }) + + it('null wildcard on empty array returns undefined (all elements are undefined)', () => { + const payload = { items: [] as unknown[] } + // An empty array mapped produces [], but every element is undefined -> undefined + assert.strictEqual(getPayloadValue(payload, ['items', null, 'x']), undefined) + }) + + it('missing intermediate key returns undefined', () => { + assert.strictEqual(getPayloadValue(fullPayload, ['nonexistent', 'deep']), undefined) + }) + + it('null intermediate value returns undefined', () => { + const payload = { a: null } + assert.strictEqual(getPayloadValue(payload as Record, ['a', 'b']), undefined) + }) + + it('empty path returns the entire payload', () => { + const payload = { x: 1 } + assert.deepStrictEqual(getPayloadValue(payload, []), { x: 1 }) + }) + + it('numeric index on non-array returns undefined', () => { + const payload = { obj: { key: 'val' } } + assert.strictEqual(getPayloadValue(payload, ['obj', 0]), undefined) + }) + + it('string key on array returns undefined', () => { + const payload = { items: ['a', 'b'] } + assert.strictEqual(getPayloadValue(payload, ['items', 'missing']), undefined) + }) + }) + + describe('resolveTypedValue', () => { + it('plain text passthrough when value_type is omitted', () => { + const result = resolveTypedValue('hello', undefined, echoResolvers, 'en') + assert.deepStrictEqual(result, { type: undefined, value: 'hello' }) + }) + + it('resolves through a registered value type resolver', () => { + const result = resolveTypedValue('49.99 EUR', 'iso_currency_amount', echoResolvers, 'en') + assert.deepStrictEqual(result, { type: 'iso_currency_amount', value: '49.99 EUR' }) + }) + + it('string resolver echoes the value', () => { + const result = resolveTypedValue('hello', 'string', echoResolvers, 'en') + assert.deepStrictEqual(result, { type: 'string', value: 'hello' }) + }) + + it('unsupported value_type (no resolver registered) returns undefined', () => { + assert.strictEqual(resolveTypedValue('val', 'unknown_type', echoResolvers, 'en'), undefined) + }) + + it('resolver returning undefined returns undefined', () => { + const resolvers: ValueTypeResolvers = { + bad: (_raw: string, _locale: string) => undefined, + } + assert.strictEqual(resolveTypedValue('val', 'bad', resolvers, 'en'), undefined) + }) + + it('passes locale to the resolver', () => { + let receivedLocale = '' + const resolvers: ValueTypeResolvers = { + test: (_raw: string, locale: string) => { + receivedLocale = locale + return _raw + }, + } + resolveTypedValue('val', 'test', resolvers, 'de-DE') + assert.strictEqual(receivedLocale, 'de-DE') + }) + }) +}) + +// =========================================================================== +// Section 3.5.1/3.5.2 -- Claim Resolution +// =========================================================================== +describe('Section 3.5.1/3.5.2 -- Claim Resolution', () => { + describe('filterSupportedDisplayEntries', () => { + it('keeps entries with no display_type (plain text)', () => { + const entries: Array<{ name: string; display_type?: string }> = [{ name: 'Plain' }] + assert.deepStrictEqual(filterSupportedDisplayEntries(entries, echoResolvers), entries) + }) + + it('keeps entries with supported display_type', () => { + const entries = [{ name: 'Formatted', display_type: 'iso_date_time' }] + assert.deepStrictEqual(filterSupportedDisplayEntries(entries, echoResolvers), entries) + }) + + it('removes entries with unsupported display_type', () => { + const entries = [{ name: 'Plain' }, { name: 'Fancy', display_type: 'fancy_unsupported' }] + assert.deepStrictEqual(filterSupportedDisplayEntries(entries, echoResolvers), [{ name: 'Plain' }]) + }) + + it('removes all entries when none are supported', () => { + const entries = [ + { name: 'A', display_type: 'exotic' }, + { name: 'B', display_type: 'other' }, + ] + assert.deepStrictEqual(filterSupportedDisplayEntries(entries, echoResolvers), []) + }) + }) + + describe('validateMandatoryClaims', () => { + it('returns true when all mandatory claims are present', () => { + assert.strictEqual(validateMandatoryClaims(typeMetadata.claims as ClaimMetadata[], fullPayload), true) + }) + + it('returns false when a mandatory claim is missing', () => { + const { amount: _, ...noAmount } = fullPayload + assert.strictEqual(validateMandatoryClaims(typeMetadata.claims as ClaimMetadata[], noAmount), false) + }) + + it('returns true when an optional claim is absent', () => { + const { date_time: _, ...noDate } = fullPayload + assert.strictEqual(validateMandatoryClaims(typeMetadata.claims as ClaimMetadata[], noDate), true) + }) + + it('returns false when a mandatory nested claim parent is missing', () => { + const { payee: _, ...noPayee } = fullPayload + assert.strictEqual(validateMandatoryClaims(typeMetadata.claims as ClaimMetadata[], noPayee), false) + }) + + it('returns true for empty claims array (no mandatory claims)', () => { + assert.strictEqual(validateMandatoryClaims([], fullPayload), true) + }) + + it('non-mandatory claim without mandatory field is treated as optional', () => { + const claims: ClaimMetadata[] = [{ path: ['optional_field'] }] + assert.strictEqual(validateMandatoryClaims(claims, {}), true) + }) + }) + + describe('validateNoUndeclaredPayloadFields', () => { + it('returns true when all payload fields are declared', () => { + assert.strictEqual(validateNoUndeclaredPayloadFields(typeMetadata.claims as ClaimMetadata[], fullPayload), true) + }) + + it('returns false when an extra top-level field is present', () => { + const payload = { ...fullPayload, undeclared: 'extra' } + assert.strictEqual(validateNoUndeclaredPayloadFields(typeMetadata.claims as ClaimMetadata[], payload), false) + }) + + it('returns false when an extra nested field is present', () => { + const payload = { + ...fullPayload, + payee: { name: 'Shop AG', id: 'DE1234', extra: 'undeclared' }, + } + assert.strictEqual(validateNoUndeclaredPayloadFields(typeMetadata.claims as ClaimMetadata[], payload), false) + }) + + it('returns true for payload with only declared nested fields', () => { + const payload = { + transaction_id: 'tx-001', + amount: '49.99 EUR', + payee: { name: 'Shop AG', id: 'DE1234' }, + } + assert.strictEqual(validateNoUndeclaredPayloadFields(typeMetadata.claims as ClaimMetadata[], payload), true) + }) + + it('returns true for empty payload with no claims', () => { + assert.strictEqual(validateNoUndeclaredPayloadFields([], {}), true) + }) + }) + + describe('resolveDisplayableClaim', () => { + const amountClaim = typeMetadata.claims[2] as ClaimMetadata & { + display: Array<{ name: string; locale?: string; display_type?: string }> + } + + it('resolves locale, label, and value correctly', () => { + const result = resolveDisplayableClaim(amountClaim, fullPayload, 'en-GB', echoResolvers) + assert.ok(result) + assert.deepStrictEqual(result.path, ['amount']) + assert.strictEqual(result.mandatory, true) + assert.deepStrictEqual(result.label, { type: undefined, value: 'Amount' }) + assert.deepStrictEqual(result.value, { type: 'iso_currency_amount', value: '49.99 EUR' }) + }) + + it('returns undefined when locale does not match any display entry', () => { + const result = resolveDisplayableClaim(amountClaim, fullPayload, 'fr-FR', echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('returns undefined when value_type has no resolver', () => { + const result = resolveDisplayableClaim(amountClaim, fullPayload, 'en-GB', emptyResolvers) + assert.strictEqual(result, undefined) + }) + + it('uses pathOverride when provided', () => { + const claim = typeMetadata.claims[3] as ClaimMetadata & { + display: Array<{ name: string; locale?: string; display_type?: string }> + } + const result = resolveDisplayableClaim(claim, fullPayload, 'en-GB', echoResolvers, ['payee', 'name']) + assert.ok(result) + assert.deepStrictEqual(result.path, ['payee', 'name']) + assert.strictEqual(result.label.value, 'Payee') + }) + }) + + describe('resolveAllClaims', () => { + it('display order follows claims array order', () => { + const result = resolveAllClaims(typeMetadata.claims as ClaimMetadata[], fullPayload, 'en-GB', echoResolvers) + assert.ok(result) + // Only displayable claims (with display array) are returned, in declaration order + assert.deepStrictEqual( + result.map((c) => c.path), + [['date_time'], ['amount'], ['payee', 'name']] + ) + }) + + it('skips optional claim absent from payload', () => { + const { date_time: _, ...noDate } = fullPayload + const result = resolveAllClaims(typeMetadata.claims as ClaimMetadata[], noDate, 'en-GB', echoResolvers) + assert.ok(result) + assert.deepStrictEqual( + result.map((c) => c.path), + [['amount'], ['payee', 'name']] + ) + }) + + it('returns undefined when a mandatory claim is missing', () => { + const { amount: _, ...noAmount } = fullPayload + const result = resolveAllClaims(typeMetadata.claims as ClaimMetadata[], noAmount, 'en-GB', echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('returns undefined when undeclared fields are present', () => { + const payload = { ...fullPayload, extra: 'undeclared' } + const result = resolveAllClaims(typeMetadata.claims as ClaimMetadata[], payload, 'en-GB', echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('returns empty array when no claims are displayable', () => { + const claims: ClaimMetadata[] = [{ path: ['transaction_id'], mandatory: true }] + const payload = { transaction_id: 'tx-001' } + const result = resolveAllClaims(claims, payload, 'en', echoResolvers) + assert.ok(result) + assert.strictEqual(result.length, 0) + }) + }) +}) + +// =========================================================================== +// Wildcard expansion (expandClaims) +// =========================================================================== +describe('Wildcard expansion', () => { + const itemNameClaim = { + path: ['items', null, 'name'], + display: [{ name: 'Item Name', locale: 'en' }], + } as unknown as ClaimMetadata + + const itemPriceClaim = { + path: ['items', null, 'price'], + value_type: 'iso_currency_amount', + display: [{ name: 'Price', locale: 'en' }], + } as unknown as ClaimMetadata + + const totalClaim = { + path: ['total'], + value_type: 'iso_currency_amount', + display: [{ name: 'Total', locale: 'en' }], + } as unknown as ClaimMetadata + + describe('expandClaims -- single-level wildcards', () => { + it('expands wildcard group per array index, interleaving group members', () => { + const payload = { + items: [ + { name: 'Widget', price: '10 EUR' }, + { name: 'Gadget', price: '20 EUR' }, + ], + } + const expanded = expandClaims([itemNameClaim, itemPriceClaim], payload) + assert.deepStrictEqual( + expanded.map((e) => e.concretePath), + [ + ['items', 0, 'name'], + ['items', 0, 'price'], + ['items', 1, 'name'], + ['items', 1, 'price'], + ] + ) + }) + + it('preserves order: non-wildcard claims stay in place, group emitted at first member position', () => { + const payload = { + total: '30 EUR', + items: [{ name: 'Widget', price: '10 EUR' }], + } + const expanded = expandClaims([totalClaim, itemNameClaim, itemPriceClaim], payload) + assert.deepStrictEqual( + expanded.map((e) => e.concretePath), + [['total'], ['items', 0, 'name'], ['items', 0, 'price']] + ) + }) + + it('empty array produces no expanded claims for that group', () => { + const payload = { items: [] as unknown[] } + const expanded = expandClaims([itemNameClaim, itemPriceClaim], payload) + assert.strictEqual(expanded.length, 0) + }) + + it('missing array produces no expanded claims for that group', () => { + const expanded = expandClaims([itemNameClaim], {}) + assert.strictEqual(expanded.length, 0) + }) + + it('mixed wildcard and non-wildcard claims: non-wildcard always emitted', () => { + const payload = { total: '30 EUR' } + // items array is missing, so wildcard claims produce nothing, but totalClaim stays + const expanded = expandClaims([totalClaim, itemNameClaim], payload) + assert.deepStrictEqual( + expanded.map((e) => e.concretePath), + [['total']] + ) + }) + }) + + describe('expandClaims -- multi-depth wildcards (Annex C)', () => { + const orderDateClaim = { + path: ['orders', null, 'date'], + display: [{ name: 'Date', locale: 'en' }], + } as unknown as ClaimMetadata + + const lineNameClaim = { + path: ['orders', null, 'items', null, 'name'], + display: [{ name: 'Name', locale: 'en' }], + } as unknown as ClaimMetadata + + const linePriceClaim = { + path: ['orders', null, 'items', null, 'price'], + value_type: 'iso_currency_amount', + display: [{ name: 'Price', locale: 'en' }], + } as unknown as ClaimMetadata + + it('recursively expands nested wildcards, inner grouped closest to leaf', () => { + const payload = { + orders: [ + { + date: '2025-01-01', + items: [ + { name: 'A', price: '10' }, + { name: 'B', price: '20' }, + ], + }, + { + date: '2025-01-02', + items: [{ name: 'C', price: '30' }], + }, + ], + } + + const expanded = expandClaims([orderDateClaim, lineNameClaim, linePriceClaim], payload) + assert.deepStrictEqual( + expanded.map((e) => e.concretePath), + [ + ['orders', 0, 'date'], + ['orders', 0, 'items', 0, 'name'], + ['orders', 0, 'items', 0, 'price'], + ['orders', 0, 'items', 1, 'name'], + ['orders', 0, 'items', 1, 'price'], + ['orders', 1, 'date'], + ['orders', 1, 'items', 0, 'name'], + ['orders', 1, 'items', 0, 'price'], + ] + ) + }) + + it('handles mixed: some group members have deeper wildcards, some do not', () => { + const payload = { + orders: [{ date: 'D1', items: [{ name: 'X' }] }], + } + + const expanded = expandClaims([orderDateClaim, lineNameClaim], payload) + assert.deepStrictEqual( + expanded.map((e) => e.concretePath), + [ + ['orders', 0, 'date'], + ['orders', 0, 'items', 0, 'name'], + ] + ) + }) + + it('inner empty array expands outer but produces nothing for inner', () => { + const payload = { + orders: [{ date: 'D1', items: [] as unknown[] }], + } + + const expanded = expandClaims([orderDateClaim, lineNameClaim, linePriceClaim], payload) + // date is a single-level wildcard member, so it gets expanded for index 0 + // inner items is empty, so lineNameClaim/linePriceClaim produce nothing + assert.deepStrictEqual( + expanded.map((e) => e.concretePath), + [['orders', 0, 'date']] + ) + }) + }) + + describe('resolveAllClaims with wildcards', () => { + it('resolves single-depth wildcard claims grouped by array index', () => { + const payload = { + total: '30 EUR', + items: [ + { name: 'Widget', price: '10 EUR' }, + { name: 'Gadget', price: '20 EUR' }, + ], + } + + const claims: ClaimMetadata[] = [totalClaim, itemNameClaim, itemPriceClaim] + const result = resolveAllClaims(claims, payload, 'en', echoResolvers) + assert.ok(result) + + assert.deepStrictEqual( + result.map((c) => c.path), + [['total'], ['items', 0, 'name'], ['items', 0, 'price'], ['items', 1, 'name'], ['items', 1, 'price']] + ) + }) + }) +}) + +// =========================================================================== +// Section 3.5.3 -- UI Label Resolution +// =========================================================================== +describe('Section 3.5.3 -- UI Label Resolution', () => { + const claims = typeMetadata.claims as ClaimMetadata[] + + describe('interpolatePlaceholders', () => { + it('{index} replaced with formatted claim value', () => { + // {2} references claims[2] which is the amount claim + const result = interpolatePlaceholders('Pay {2}', claims, fullPayload, echoResolvers, 'en') + assert.strictEqual(result, 'Pay 49.99 EUR') + }) + + it('out-of-bounds placeholder kept as literal text', () => { + const result = interpolatePlaceholders('Ref {99}', claims, fullPayload, echoResolvers, 'en') + assert.strictEqual(result, 'Ref {99}') + }) + + it('missing claim value discards entire entry (returns undefined)', () => { + const { amount: _, ...noAmount } = fullPayload + const result = interpolatePlaceholders('Pay {2}', claims, noAmount, echoResolvers, 'en') + assert.strictEqual(result, undefined) + }) + + it('multiple placeholders are all replaced', () => { + const result = interpolatePlaceholders('{2} to {3}', claims, fullPayload, echoResolvers, 'en') + assert.strictEqual(result, '49.99 EUR to Shop AG') + }) + + it('template without placeholders passes through unchanged', () => { + const result = interpolatePlaceholders('Confirm', claims, fullPayload, echoResolvers, 'en') + assert.strictEqual(result, 'Confirm') + }) + + it('empty template returns empty string', () => { + const result = interpolatePlaceholders('', claims, fullPayload, echoResolvers, 'en') + assert.strictEqual(result, '') + }) + + it('adjacent placeholders both replaced', () => { + const result = interpolatePlaceholders('{0}{2}', claims, fullPayload, echoResolvers, 'en') + assert.strictEqual(result, 'tx-00149.99 EUR') + }) + + it('unsupported value_type on referenced claim discards entry', () => { + // claims[1] is date_time with value_type iso_date_time + // Using resolvers that do not support iso_date_time + const resolvers: ValueTypeResolvers = { + iso_currency_amount: (v: string) => String(v), + } + const result = interpolatePlaceholders('Date: {1}', claims, fullPayload, resolvers, 'en') + assert.strictEqual(result, undefined) + }) + }) + + describe('resolveUiLabel', () => { + const affirmativeEntries = typeMetadata.ui_labels.affirmative_action_label as UiLabelEntry[] + + it('locale match resolves the correct label', () => { + const result = resolveUiLabel(affirmativeEntries, 'de-DE', claims, fullPayload, echoResolvers) + assert.ok(result) + assert.strictEqual(result.value, 'Zahlung bestaetigen') + }) + + it('locale match resolves en-GB label', () => { + const result = resolveUiLabel(affirmativeEntries, 'en-GB', claims, fullPayload, echoResolvers) + assert.ok(result) + assert.strictEqual(result.value, 'Confirm Payment') + }) + + it('falls back to default entry when locale-matched entry is discarded', () => { + const entries: UiLabelEntry[] = [{ locale: 'en', value: 'Pay {2}' }, { value: 'Fallback' }] + const { amount: _, ...noAmount } = fullPayload + const result = resolveUiLabel(entries, 'en', claims, noAmount, echoResolvers) + assert.ok(result) + assert.strictEqual(result.value, 'Fallback') + }) + + it('returns undefined when both locale entry and default are discarded', () => { + const entries: UiLabelEntry[] = [{ locale: 'en', value: 'Pay {2}' }, { value: 'Default {2}' }] + const { amount: _, ...noAmount } = fullPayload + const result = resolveUiLabel(entries, 'en', claims, noAmount, echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('returns undefined when no entry matches the locale and no default exists', () => { + const entries: UiLabelEntry[] = [{ locale: 'de', value: 'Nur Deutsch' }] + const result = resolveUiLabel(entries, 'fr', claims, fullPayload, echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('entry with value_type is resolved through that type', () => { + const entries: UiLabelEntry[] = [{ locale: 'en', value: 'OK', value_type: 'string' }] + const result = resolveUiLabel(entries, 'en', claims, fullPayload, echoResolvers) + assert.ok(result) + assert.deepStrictEqual(result, { type: 'string', value: 'OK' }) + }) + }) + + describe('resolveAllUiLabels', () => { + it('affirmative_action_label is required: succeeds when present', () => { + const result = resolveAllUiLabels( + typeMetadata.ui_labels as Record, + 'en-GB', + claims, + fullPayload, + echoResolvers + ) + assert.ok(result) + assert.ok(result.affirmative_action_label) + assert.strictEqual(result.affirmative_action_label.value, 'Confirm Payment') + }) + + it('returns undefined when affirmative_action_label fails', () => { + const uiLabels: Record = { + affirmative_action_label: [{ locale: 'fr', value: 'Confirmer' }], // no 'en' match, no default + } + const result = resolveAllUiLabels(uiLabels, 'en', claims, fullPayload, echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('optional labels failing are omitted from output', () => { + const uiLabels: Record = { + affirmative_action_label: [{ locale: 'en', value: 'OK' }], + denial_action_label: [{ locale: 'fr', value: 'Non' }], // no 'en' match + } + const result = resolveAllUiLabels(uiLabels, 'en', claims, fullPayload, echoResolvers) + assert.ok(result) + assert.strictEqual(result.affirmative_action_label.value, 'OK') + assert.strictEqual(result.denial_action_label, undefined) + }) + + it('resolves all labels when all match', () => { + const result = resolveAllUiLabels( + typeMetadata.ui_labels as Record, + 'en-GB', + claims, + fullPayload, + echoResolvers + ) + assert.ok(result) + assert.ok(result.affirmative_action_label) + assert.ok(result.denial_action_label) + assert.ok(result.transaction_title) + assert.strictEqual(result.transaction_title.value, 'Payment to Shop AG') + }) + + it('returns undefined when affirmative_action_label key is missing entirely', () => { + const uiLabels: Record = { + denial_action_label: [{ locale: 'en', value: 'Cancel' }], + } + const result = resolveAllUiLabels(uiLabels, 'en', claims, fullPayload, echoResolvers) + assert.strictEqual(result, undefined) + }) + }) +}) + +// =========================================================================== +// Section 3.5.4 -- Transaction Display +// =========================================================================== +describe('Section 3.5.4 -- Transaction Display', () => { + describe('verifyLocaleSupport', () => { + it('returns true when all display arrays match the locale', () => { + assert.strictEqual(verifyLocaleSupport(typeMetadata, 'en-GB', echoResolvers), true) + }) + + it('returns false when any display array fails to match', () => { + assert.strictEqual(verifyLocaleSupport(typeMetadata, 'fr-FR', echoResolvers), false) + }) + + it('default entries fill gaps (no locale field always matches)', () => { + const meta: TransactionDataType = { + claims: [ + { + path: ['x'], + display: [{ name: 'Default Label' }], // no locale = default + }, + ], + ui_labels: { + affirmative_action_label: [{ value: 'OK' }], // no locale = default + }, + } + assert.strictEqual(verifyLocaleSupport(meta, 'zh-CN', echoResolvers), true) + }) + + it('unsupported display_type entries excluded before matching', () => { + const meta: TransactionDataType = { + claims: [ + { + path: ['x'], + display: [{ name: 'Fancy', locale: 'en', display_type: 'fancy_unsupported' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ locale: 'en', value: 'OK' }], + }, + } + // After filtering out the unsupported entry, no display entries remain -> false + assert.strictEqual(verifyLocaleSupport(meta, 'en', echoResolvers), false) + }) + + it('checks ui_labels too: fails if a ui_label array has no matching entry', () => { + const meta: TransactionDataType = { + claims: [ + { + path: ['x'], + display: [{ name: 'X', locale: 'en' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ locale: 'de', value: 'Bestaetigen' }], // no 'en' + }, + } + assert.strictEqual(verifyLocaleSupport(meta, 'en', echoResolvers), false) + }) + }) + + describe('resolveTransactionDisplay', () => { + const transactionData: ScaTransactionDataEntry = { + type: TYPE_KEY, + credential_ids: ['cred-1'], + payload: fullPayload, + } + + it('full orchestration with single locale', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, 'en-GB', echoResolvers) + assert.ok(result) + assert.strictEqual(result.locale, 'en-GB') + assert.strictEqual(result.type, TYPE_KEY) + assert.ok(result.claims.length > 0) + assert.ok(result.ui_labels.affirmative_action_label) + }) + + it('output includes locale and type fields', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, 'de-DE', echoResolvers) + assert.ok(result) + assert.strictEqual(result.locale, 'de-DE') + assert.strictEqual(result.type, TYPE_KEY) + }) + + it('locale priority list: first successful wins', () => { + // fr-FR will fail, then en-GB succeeds + const result = resolveTransactionDisplay(transactionData, credentialMetadata, ['fr-FR', 'en-GB'], echoResolvers) + assert.ok(result) + assert.strictEqual(result.locale, 'en-GB') + }) + + it('locale priority list: first match is preferred', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, ['de-DE', 'en-GB'], echoResolvers) + assert.ok(result) + assert.strictEqual(result.locale, 'de-DE') + }) + + it('all locales fail returns undefined', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, ['fr-FR', 'ja-JP'], echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('type not found returns undefined', () => { + const td: ScaTransactionDataEntry = { + type: 'urn:nonexistent:type', + credential_ids: ['cred-1'], + payload: fullPayload, + } + const result = resolveTransactionDisplay(td, credentialMetadata, 'en-GB', echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('mandatory claim missing returns undefined', () => { + const { amount: _, ...noAmount } = fullPayload + const td: ScaTransactionDataEntry = { + type: TYPE_KEY, + credential_ids: ['cred-1'], + payload: noAmount, + } + const result = resolveTransactionDisplay(td, credentialMetadata, 'en-GB', echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('undeclared nested field returns undefined', () => { + const td: ScaTransactionDataEntry = { + type: TYPE_KEY, + credential_ids: ['cred-1'], + payload: { + ...fullPayload, + payee: { name: 'Shop AG', id: 'DE1234', extra: 'undeclared' }, + }, + } + const result = resolveTransactionDisplay(td, credentialMetadata, 'en-GB', echoResolvers) + assert.strictEqual(result, undefined) + }) + + it('accepts entry when unsupported value_type is on absent optional claim', () => { + const metaWithUnsupported: ScaCredentialMetadata = { + transaction_data_types: { + [TYPE_KEY]: { + claims: [ + { path: ['transaction_id'], mandatory: true }, + { + path: ['amount'], + mandatory: true, + value_type: 'iso_currency_amount', + display: [{ locale: 'en-GB', name: 'Amount' }], + }, + { + path: ['optional_field'], + value_type: 'unsupported_exotic_type', + display: [{ locale: 'en-GB', name: 'Optional' }], + }, + ], + ui_labels: { + affirmative_action_label: [{ locale: 'en-GB', value: 'Confirm' }], + }, + }, + }, + } + const td: ScaTransactionDataEntry = { + type: TYPE_KEY, + credential_ids: ['cred-1'], + payload: { transaction_id: 'tx-001', amount: '49.99 EUR' }, + } + const result = resolveTransactionDisplay(td, metaWithUnsupported, 'en-GB', echoResolvers) + assert.ok(result) + }) + + it('resolves claims in declaration order', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, 'en-GB', echoResolvers) + assert.ok(result) + assert.deepStrictEqual( + result.claims.map((c) => c.path), + [['date_time'], ['amount'], ['payee', 'name']] + ) + }) + + it('resolves ui_labels including placeholder interpolation', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, 'en-GB', echoResolvers) + assert.ok(result) + assert.strictEqual(result.ui_labels.transaction_title.value, 'Payment to Shop AG') + }) + + it('de-DE locale resolves German labels and placeholder', () => { + const result = resolveTransactionDisplay(transactionData, credentialMetadata, 'de-DE', echoResolvers) + assert.ok(result) + assert.strictEqual(result.ui_labels.affirmative_action_label.value, 'Zahlung bestaetigen') + assert.strictEqual(result.ui_labels.transaction_title.value, 'Zahlung an Shop AG') + }) + }) +}) diff --git a/tests/validation.test.ts b/tests/validation.test.ts new file mode 100644 index 0000000..13b9b9c --- /dev/null +++ b/tests/validation.test.ts @@ -0,0 +1,858 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' +import { + createScaTypeMatcher, + defaultScaTypeMatcher, + isScaAttestationMetadata, + zBaseTransaction, + zClaimMetadata, + zCredentialMetadata, + zScaCredentialMetadata, + zScaTransactionDataEntry, + zTransactionDataEntry, + zTransactionDataType, + zUiLabels, +} from '@animo-id/eudi-wallet-ts12-validation' + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +/** Spec example URN: urn:eudi:sca:eu.europa.ec:payment:single:1 (Section 2.3). */ +const SCA_PAYMENT_TYPE = 'urn:eudi:sca:eu.europa.ec:payment:single:1' + +/** Minimal internal claim (path only, no display). */ +const internalClaim = { path: ['amount'] } + +/** Displayable claim with display array and value_type. */ +const displayableClaim = { + path: ['beneficiary_name'], + display: [{ name: 'Beneficiary', locale: 'en' }], + value_type: 'string', +} + +/** Minimal valid UI labels (only the REQUIRED affirmative_action_label). */ +const minimalUiLabels = { + affirmative_action_label: [{ value: 'Confirm', locale: 'en' }], +} + +/** Full UI labels with all known optional elements. */ +const fullUiLabels = { + affirmative_action_label: [{ value: 'Approve', locale: 'en' }], + denial_action_label: [{ value: 'Cancel', locale: 'en' }], + transaction_title: [{ value: 'Payment', locale: 'en' }], + security_hint: [{ value: 'Check details', locale: 'en' }], +} + +/** A valid transaction_data_types entry (claims + ui_labels). */ +const validTransactionDataType = { + claims: [internalClaim, displayableClaim], + ui_labels: minimalUiLabels, +} + +/** Full SCA credential metadata (Section 2.3 style). */ +const fullScaCredentialMetadata = { + transaction_data_types: { + [SCA_PAYMENT_TYPE]: validTransactionDataType, + }, +} + +/** Valid SCA transaction data entry (Section 4.2). */ +const validScaTransactionDataEntry = { + type: SCA_PAYMENT_TYPE, + credential_ids: ['payment_credential'], + payload: { + amount: '100.00', + beneficiary_name: 'Alice', + }, +} + +/** Valid Funke QES transaction data entry. */ +const validFunkeQesEntry = { + type: 'funke_qes', + credential_ids: ['qes_credential'], + signatureQualifier: 'eu_eidas_qes' as const, + documentDigests: [ + { + label: 'Contract.pdf', + hash: 'abc123==', + hashAlgorithmOID: '2.16.840.1.101.3.4.2.1', + }, + ], +} + +// =========================================================================== +// Section 3.1 -- SCA Attestation Identification +// "The Wallet Unit determines whether an Attestation is an SCA Attestation +// by checking the transaction_data_types keys against known SCA URN prefixes." +// =========================================================================== + +describe('Section 3.1 -- SCA Attestation Identification', () => { + // ------------------------------------------------------------------------- + // defaultScaTypeMatcher + // "Default matcher recognises the urn:eudi:sca: prefix per TS12." + // ------------------------------------------------------------------------- + describe('defaultScaTypeMatcher', () => { + it('matches a valid urn:eudi:sca: prefixed URN', () => { + assert.strictEqual(defaultScaTypeMatcher(SCA_PAYMENT_TYPE), true) + }) + + it('rejects a non-SCA type string', () => { + assert.strictEqual(defaultScaTypeMatcher('funke_qes'), false) + }) + + it('rejects the prefix itself without trailing colon', () => { + assert.strictEqual(defaultScaTypeMatcher('urn:eudi:sca'), false) + }) + + it('rejects an empty string', () => { + assert.strictEqual(defaultScaTypeMatcher(''), false) + }) + + it('is case-sensitive (URN scheme is lowercase per spec)', () => { + assert.strictEqual(defaultScaTypeMatcher('URN:EUDI:SCA:something'), false) + assert.strictEqual(defaultScaTypeMatcher('Urn:Eudi:Sca:something'), false) + }) + }) + + // ------------------------------------------------------------------------- + // createScaTypeMatcher + // "Create an SCA type matcher that matches one or more URN prefixes." + // ------------------------------------------------------------------------- + describe('createScaTypeMatcher', () => { + it('matches a single custom prefix', () => { + const matcher = createScaTypeMatcher('urn:custom:sca:') + assert.strictEqual(matcher('urn:custom:sca:com.example:payment:single:1'), true) + assert.strictEqual(matcher('urn:eudi:sca:eu.europa.ec:payment:single:1'), false) + }) + + it('matches multiple prefixes', () => { + const matcher = createScaTypeMatcher('urn:eudi:sca:', 'urn:paso:sca:') + assert.strictEqual(matcher('urn:eudi:sca:eu.europa.ec:payment:single:1'), true) + assert.strictEqual(matcher('urn:paso:sca:com.example:signing:1'), true) + assert.strictEqual(matcher('other:type'), false) + }) + }) + + // ------------------------------------------------------------------------- + // isScaAttestationMetadata + // "Checks whether credential metadata describes an SCA Attestation by + // verifying that transaction_data_types contains at least one key + // recognized by the given matcher." + // ------------------------------------------------------------------------- + describe('isScaAttestationMetadata', () => { + it('returns true when at least one key matches the default matcher', () => { + assert.strictEqual( + isScaAttestationMetadata({ + transaction_data_types: { [SCA_PAYMENT_TYPE]: {} }, + }), + true + ) + }) + + it('returns true when keys are mixed (SCA and non-SCA)', () => { + assert.strictEqual( + isScaAttestationMetadata({ + transaction_data_types: { + [SCA_PAYMENT_TYPE]: {}, + funke_qes: {}, + }, + }), + true + ) + }) + + it('returns false when no keys match the SCA prefix', () => { + assert.strictEqual( + isScaAttestationMetadata({ + transaction_data_types: { funke_qes: {} }, + }), + false + ) + }) + + it('returns false when transaction_data_types is missing', () => { + assert.strictEqual(isScaAttestationMetadata({}), false) + }) + + it('returns false when transaction_data_types is an empty object', () => { + assert.strictEqual(isScaAttestationMetadata({ transaction_data_types: {} }), false) + }) + }) + + // ------------------------------------------------------------------------- + // isScaAttestationMetadata with custom matcher + // ------------------------------------------------------------------------- + describe('isScaAttestationMetadata with custom matcher', () => { + it('matches when keys use the custom prefix', () => { + const matcher = createScaTypeMatcher('urn:paso:sca:') + assert.strictEqual( + isScaAttestationMetadata({ transaction_data_types: { 'urn:paso:sca:com.example:payment:1': {} } }, matcher), + true + ) + }) + + it('does not match the default eudi prefix when only custom is configured', () => { + const matcher = createScaTypeMatcher('urn:paso:sca:') + assert.strictEqual( + isScaAttestationMetadata({ transaction_data_types: { [SCA_PAYMENT_TYPE]: {} } }, matcher), + false + ) + }) + }) +}) + +// =========================================================================== +// Section 3.5.2 -- Claim Metadata +// "Claims that are relevant to the User's consent MUST include a display +// array. Claims without a display array MUST be internal values." +// =========================================================================== + +describe('Section 3.5.2 -- Claim Metadata', () => { + // ------------------------------------------------------------------------- + // Displayable vs internal claims + // ------------------------------------------------------------------------- + describe('zClaimMetadata -- displayable claims', () => { + it('accepts a displayable claim with display and optional value_type', () => { + const result = zClaimMetadata.parse(displayableClaim) + assert.deepStrictEqual(result.path, ['beneficiary_name']) + assert.ok('display' in result && Array.isArray(result.display)) + assert.ok('value_type' in result && result.value_type === 'string') + }) + + it('accepts a displayable claim with display but without value_type', () => { + const result = zClaimMetadata.parse({ + path: ['beneficiary_name'], + display: [{ name: 'Beneficiary' }], + }) + assert.ok('display' in result && result.display.length === 1) + }) + }) + + describe('zClaimMetadata -- internal claims', () => { + it('accepts an internal claim (no display, no value_type)', () => { + const result = zClaimMetadata.parse({ path: ['nonce'] }) + assert.deepStrictEqual(result.path, ['nonce']) + }) + + it('accepts an internal claim with mandatory boolean', () => { + const result = zClaimMetadata.parse({ path: ['nonce'], mandatory: true }) + assert.deepStrictEqual(result.path, ['nonce']) + assert.strictEqual(result.mandatory, true) + }) + }) + + // ------------------------------------------------------------------------- + // value_type MUST NOT appear without display + // "The value_type parameter MUST NOT be used on claims without a display array." + // ------------------------------------------------------------------------- + describe('zClaimMetadata -- value_type without display is rejected', () => { + it('rejects value_type on an internal claim (no display)', () => { + assert.throws( + () => zClaimMetadata.parse({ path: ['nonce'], value_type: 'string' }), + (e: unknown) => e instanceof Error && e.constructor.name === 'ZodError' + ) + }) + }) + + // ------------------------------------------------------------------------- + // Claims Path Pointer components + // "[OID4VCI] Appendix B: a claims path pointer MUST be a non-empty array + // of strings, nulls and integers." + // ------------------------------------------------------------------------- + describe('zClaimMetadata -- claim path components', () => { + it('accepts a path with string components', () => { + const result = zClaimMetadata.parse({ path: ['a', 'b', 'c'] }) + assert.deepStrictEqual(result.path, ['a', 'b', 'c']) + }) + + it('accepts a path with null component (wildcard)', () => { + const result = zClaimMetadata.parse({ path: ['items', null, 'name'] }) + assert.deepStrictEqual(result.path, ['items', null, 'name']) + }) + + it('accepts a path with integer component (array index)', () => { + const result = zClaimMetadata.parse({ path: ['items', 0, 'name'] }) + assert.deepStrictEqual(result.path, ['items', 0, 'name']) + }) + + it('accepts a path mixing string, null, and integer components', () => { + const result = zClaimMetadata.parse({ path: ['data', null, 2, 'value'] }) + assert.deepStrictEqual(result.path, ['data', null, 2, 'value']) + }) + + it('rejects a non-integer number in path', () => { + assert.throws( + () => zClaimMetadata.parse({ path: ['items', 1.5] }), + (e: unknown) => e instanceof Error && e.constructor.name === 'ZodError' + ) + }) + }) + + // ------------------------------------------------------------------------- + // mandatory is optional boolean + // ------------------------------------------------------------------------- + describe('zClaimMetadata -- mandatory field', () => { + it('mandatory defaults to undefined when omitted', () => { + const result = zClaimMetadata.parse({ path: ['field'] }) + assert.strictEqual(result.mandatory, undefined) + }) + + it('accepts mandatory: false', () => { + const result = zClaimMetadata.parse({ path: ['field'], mandatory: false }) + assert.strictEqual(result.mandatory, false) + }) + }) + + // ------------------------------------------------------------------------- + // Display entry structure + // "name REQUIRED, locale OPTIONAL, display_type OPTIONAL." + // ------------------------------------------------------------------------- + describe('zClaimMetadata -- display entry structure', () => { + it('display entry requires name', () => { + assert.throws(() => + zClaimMetadata.parse({ + path: ['x'], + display: [{ locale: 'en' }], + }) + ) + }) + + it('display entry accepts name only (locale and display_type optional)', () => { + const result = zClaimMetadata.parse({ + path: ['x'], + display: [{ name: 'Label' }], + }) + assert.ok('display' in result && result.display[0].name === 'Label') + }) + + it('display entry accepts name with locale', () => { + const result = zClaimMetadata.parse({ + path: ['x'], + display: [{ name: 'Label', locale: 'en' }], + }) + assert.ok('display' in result && result.display[0].locale === 'en') + }) + + it('display entry accepts name with display_type', () => { + const result = zClaimMetadata.parse({ + path: ['x'], + display: [{ name: 'Amount', display_type: 'currency' }], + }) + assert.ok('display' in result && result.display[0].display_type === 'currency') + }) + }) +}) + +// =========================================================================== +// Section 3.5.3 -- UI Labels +// "affirmative_action_label: REQUIRED. denial_action_label, transaction_title, +// security_hint: OPTIONAL. Additional UI element identifiers MAY be defined." +// =========================================================================== + +describe('Section 3.5.3 -- UI Labels', () => { + // ------------------------------------------------------------------------- + // Required / optional fields + // ------------------------------------------------------------------------- + describe('zUiLabels -- required and optional fields', () => { + it('affirmative_action_label is REQUIRED', () => { + assert.throws(() => zUiLabels.parse({})) + }) + + it('accepts with only the required affirmative_action_label', () => { + const result = zUiLabels.parse(minimalUiLabels) + assert.ok(result.affirmative_action_label) + assert.strictEqual(result.denial_action_label, undefined) + assert.strictEqual(result.transaction_title, undefined) + assert.strictEqual(result.security_hint, undefined) + }) + + it('denial_action_label is OPTIONAL', () => { + const result = zUiLabels.parse({ + ...minimalUiLabels, + denial_action_label: [{ value: 'Cancel' }], + }) + assert.ok(result.denial_action_label) + }) + + it('transaction_title is OPTIONAL', () => { + const result = zUiLabels.parse({ + ...minimalUiLabels, + transaction_title: [{ value: 'Payment Authorization' }], + }) + assert.ok(result.transaction_title) + }) + + it('security_hint is OPTIONAL', () => { + const result = zUiLabels.parse({ + ...minimalUiLabels, + security_hint: [{ value: 'Verify details carefully' }], + }) + assert.ok(result.security_hint) + }) + + it('accepts all known optional fields together', () => { + const result = zUiLabels.parse(fullUiLabels) + assert.ok(result.affirmative_action_label) + assert.ok(result.denial_action_label) + assert.ok(result.transaction_title) + assert.ok(result.security_hint) + }) + }) + + // ------------------------------------------------------------------------- + // Catchall for additional UI element identifiers + // "Additional UI element identifiers MAY be defined." + // ------------------------------------------------------------------------- + describe('zUiLabels -- additional UI element identifiers (catchall)', () => { + it('allows additional UI element identifiers', () => { + const input = { + ...minimalUiLabels, + custom_warning: [{ value: 'Be careful', locale: 'en' }], + progress_label: [{ value: 'Processing...' }], + } + const result = zUiLabels.parse(input) + assert.ok(result.custom_warning) + assert.ok(result.progress_label) + }) + }) + + // ------------------------------------------------------------------------- + // UI label entry structure + // "value REQUIRED, locale OPTIONAL, value_type OPTIONAL." + // ------------------------------------------------------------------------- + describe('zUiLabels -- label entry structure', () => { + it('label entry requires value', () => { + assert.throws(() => + zUiLabels.parse({ + affirmative_action_label: [{ locale: 'en' }], + }) + ) + }) + + it('label entry accepts value only (locale and value_type optional)', () => { + const result = zUiLabels.parse({ + affirmative_action_label: [{ value: 'OK' }], + }) + assert.strictEqual(result.affirmative_action_label[0].value, 'OK') + assert.strictEqual(result.affirmative_action_label[0].locale, undefined) + assert.strictEqual(result.affirmative_action_label[0].value_type, undefined) + }) + + it('label entry accepts locale', () => { + const result = zUiLabels.parse({ + affirmative_action_label: [{ value: 'OK', locale: 'en' }], + }) + assert.strictEqual(result.affirmative_action_label[0].locale, 'en') + }) + + it('label entry accepts value_type', () => { + const result = zUiLabels.parse({ + affirmative_action_label: [{ value: 'Pay {0} EUR', value_type: 'template' }], + }) + assert.strictEqual(result.affirmative_action_label[0].value_type, 'template') + }) + }) +}) + +// =========================================================================== +// Section 4.1 -- Transaction Data Types +// "Each entry describes the claims and UI labels for one transaction data +// type. Additional parameters MAY be defined." +// =========================================================================== + +describe('Section 4.1 -- Transaction Data Types', () => { + // ------------------------------------------------------------------------- + // zTransactionDataType + // ------------------------------------------------------------------------- + describe('zTransactionDataType', () => { + it('claims REQUIRED and ui_labels REQUIRED', () => { + const result = zTransactionDataType.parse(validTransactionDataType) + assert.ok(Array.isArray(result.claims)) + assert.ok(result.ui_labels) + }) + + it('rejects missing claims', () => { + assert.throws(() => + zTransactionDataType.parse({ + ui_labels: minimalUiLabels, + }) + ) + }) + + it('rejects missing ui_labels', () => { + assert.throws(() => + zTransactionDataType.parse({ + claims: [internalClaim], + }) + ) + }) + + it('additional parameters allowed (.loose())', () => { + const input = { + ...validTransactionDataType, + some_extension: 'custom_value', + version: 2, + } + const result = zTransactionDataType.parse(input) + assert.ok(result) + }) + }) + + // ------------------------------------------------------------------------- + // Credential Metadata + // ------------------------------------------------------------------------- + describe('zCredentialMetadata', () => { + it('transaction_data_types is optional', () => { + const result = zCredentialMetadata.parse({}) + assert.strictEqual(result.transaction_data_types, undefined) + }) + + it('accepts transaction_data_types when present', () => { + const result = zCredentialMetadata.parse(fullScaCredentialMetadata) + assert.ok(result.transaction_data_types) + }) + + it('allows extra fields (.loose())', () => { + const result = zCredentialMetadata.parse({ + format: 'vc+sd-jwt', + vct: 'urn:example:type', + }) + assert.ok(result) + }) + }) + + describe('zScaCredentialMetadata', () => { + it('requires transaction_data_types', () => { + assert.throws(() => zScaCredentialMetadata.parse({})) + }) + + it('accepts full spec example', () => { + const result = zScaCredentialMetadata.parse(fullScaCredentialMetadata) + assert.ok(result.transaction_data_types) + assert.ok(result.transaction_data_types[SCA_PAYMENT_TYPE]) + }) + + it('allows extra OID4VCI fields (.loose())', () => { + const input = { + ...fullScaCredentialMetadata, + display: [{ name: 'SCA Attestation', locale: 'en' }], + format: 'vc+sd-jwt', + } + const result = zScaCredentialMetadata.parse(input) + assert.ok(result) + }) + }) + + // ------------------------------------------------------------------------- + // Credential display entry + // "[OID4VCI] Section 12.2.4: name required, locale/description/logo/colors optional." + // ------------------------------------------------------------------------- + describe('Credential display entry', () => { + it('name is required', () => { + assert.throws(() => + zCredentialMetadata.parse({ + display: [{ locale: 'en' }], + }) + ) + }) + + it('accepts name only', () => { + const result = zCredentialMetadata.parse({ + display: [{ name: 'My Credential' }], + }) + assert.ok(result.display) + assert.strictEqual(result.display?.[0].name, 'My Credential') + }) + + it('accepts all optional fields: locale, description, logo, colors', () => { + const result = zCredentialMetadata.parse({ + display: [ + { + name: 'Payment SCA', + locale: 'en', + description: 'SCA attestation for payments', + logo: { uri: 'https://example.com/logo.png', alt_text: 'Logo' }, + background_color: '#FFFFFF', + text_color: '#000000', + }, + ], + }) + assert.ok(result.display) + const entry = result.display?.[0] + assert.strictEqual(entry.name, 'Payment SCA') + assert.strictEqual(entry.locale, 'en') + assert.strictEqual(entry.description, 'SCA attestation for payments') + assert.ok(entry.logo) + assert.strictEqual(entry.logo?.uri, 'https://example.com/logo.png') + assert.strictEqual(entry.background_color, '#FFFFFF') + assert.strictEqual(entry.text_color, '#000000') + }) + }) +}) + +// =========================================================================== +// Section 4.2 -- Transaction Data Entry +// "The type field is a plain string. credential_ids is REQUIRED non-empty. +// payload is REQUIRED (generic JSON object)." +// =========================================================================== + +describe('Section 4.2 -- Transaction Data Entry', () => { + // ------------------------------------------------------------------------- + // zScaTransactionDataEntry + // ------------------------------------------------------------------------- + describe('zScaTransactionDataEntry', () => { + it('type REQUIRED, credential_ids REQUIRED non-empty, payload REQUIRED object', () => { + const result = zScaTransactionDataEntry.parse(validScaTransactionDataEntry) + assert.strictEqual(result.type, SCA_PAYMENT_TYPE) + assert.ok(result.credential_ids.length > 0) + assert.ok(result.payload) + }) + + it('rejects missing payload', () => { + assert.throws(() => + zScaTransactionDataEntry.parse({ + type: SCA_PAYMENT_TYPE, + credential_ids: ['cred1'], + }) + ) + }) + + it('rejects missing type', () => { + assert.throws(() => + zScaTransactionDataEntry.parse({ + credential_ids: ['cred1'], + payload: { amount: '10' }, + }) + ) + }) + + it('rejects missing credential_ids', () => { + assert.throws(() => + zScaTransactionDataEntry.parse({ + type: SCA_PAYMENT_TYPE, + payload: { amount: '10' }, + }) + ) + }) + + it('payload accepts arbitrary nested JSON', () => { + const result = zScaTransactionDataEntry.parse({ + type: SCA_PAYMENT_TYPE, + credential_ids: ['cred1'], + payload: { + amount: '100.00', + currency: 'EUR', + beneficiary: { + name: 'Alice', + iban: 'DE89370400440532013000', + }, + tags: ['urgent', 'domestic'], + metadata: null, + }, + }) + assert.strictEqual((result.payload as Record).amount, '100.00') + assert.ok((result.payload as Record).beneficiary) + }) + + it('transaction_data_hashes_alg is optional', () => { + const result = zScaTransactionDataEntry.parse(validScaTransactionDataEntry) + assert.strictEqual(result.transaction_data_hashes_alg, undefined) + }) + + it('transaction_data_hashes_alg accepted when present and non-empty', () => { + const result = zScaTransactionDataEntry.parse({ + ...validScaTransactionDataEntry, + transaction_data_hashes_alg: ['sha-256'], + }) + assert.deepStrictEqual(result.transaction_data_hashes_alg, ['sha-256']) + }) + + it('transaction_data_hashes_alg rejects empty array', () => { + assert.throws(() => + zScaTransactionDataEntry.parse({ + ...validScaTransactionDataEntry, + transaction_data_hashes_alg: [], + }) + ) + }) + }) + + // ------------------------------------------------------------------------- + // zBaseTransaction + // "type and credential_ids required, empty credential_ids rejected." + // ------------------------------------------------------------------------- + describe('zBaseTransaction', () => { + it('type and credential_ids required', () => { + const result = zBaseTransaction.parse({ + type: 'some_type', + credential_ids: ['cred1'], + }) + assert.strictEqual(result.type, 'some_type') + assert.deepStrictEqual(result.credential_ids, ['cred1']) + }) + + it('rejects empty credential_ids', () => { + assert.throws(() => + zBaseTransaction.parse({ + type: 'some_type', + credential_ids: [], + }) + ) + }) + + it('accepts multiple credential_ids', () => { + const result = zBaseTransaction.parse({ + type: 'some_type', + credential_ids: ['cred1', 'cred2', 'cred3'], + }) + assert.strictEqual(result.credential_ids.length, 3) + }) + + it('rejects missing type', () => { + assert.throws(() => + zBaseTransaction.parse({ + credential_ids: ['cred1'], + }) + ) + }) + + it('rejects missing credential_ids', () => { + assert.throws(() => + zBaseTransaction.parse({ + type: 'some_type', + }) + ) + }) + }) + + // ------------------------------------------------------------------------- + // zTransactionDataEntry union + // "Accepts SCA entries and Funke QES entries." + // ------------------------------------------------------------------------- + describe('zTransactionDataEntry union', () => { + it('accepts an SCA transaction data entry', () => { + const result = zTransactionDataEntry.parse(validScaTransactionDataEntry) + assert.strictEqual(result.type, SCA_PAYMENT_TYPE) + }) + + it('accepts a Funke QES transaction data entry', () => { + const result = zTransactionDataEntry.parse(validFunkeQesEntry) + assert.strictEqual(result.type, 'funke_qes') + assert.ok('signatureQualifier' in result) + }) + + it('rejects an entry that matches neither variant', () => { + assert.throws(() => + zTransactionDataEntry.parse({ + type: 'unknown_type', + credential_ids: ['cred1'], + // no payload (SCA fails) and no signatureQualifier/documentDigests (Funke fails) + }) + ) + }) + }) +}) + +// =========================================================================== +// Section 2.3 -- End-to-end spec example +// "Full credential metadata and transaction data entry from the spec example +// parse through all schemas." +// =========================================================================== + +describe('Section 2.3 -- End-to-end spec example', () => { + /** Comprehensive credential metadata modelled after the Section 2.3 example. */ + const specCredentialMetadata = { + display: [ + { + name: 'Payment SCA Attestation', + locale: 'en', + description: 'Attestation for payment transaction signing', + logo: { uri: 'https://example.com/logo.png', alt_text: 'Bank Logo' }, + background_color: '#003366', + text_color: '#FFFFFF', + }, + ], + transaction_data_types: { + [SCA_PAYMENT_TYPE]: { + claims: [ + { path: ['amount'], mandatory: true, display: [{ name: 'Amount', locale: 'en' }], value_type: 'currency' }, + { path: ['currency'], mandatory: true, display: [{ name: 'Currency', locale: 'en' }] }, + { + path: ['beneficiary_name'], + display: [{ name: 'Beneficiary', locale: 'en' }], + value_type: 'string', + }, + { path: ['beneficiary_iban'], display: [{ name: 'IBAN', locale: 'en' }] }, + { path: ['nonce'] }, + ], + ui_labels: { + affirmative_action_label: [ + { value: 'Approve payment of {0} {1} to {2}', locale: 'en' }, + { value: 'Zahlung von {0} {1} an {2} genehmigen', locale: 'de' }, + ], + denial_action_label: [ + { value: 'Cancel', locale: 'en' }, + { value: 'Abbrechen', locale: 'de' }, + ], + transaction_title: [ + { value: 'Payment Authorization', locale: 'en' }, + { value: 'Zahlungsfreigabe', locale: 'de' }, + ], + security_hint: [{ value: 'Verify all details before confirming', locale: 'en' }], + }, + }, + }, + } + + /** Corresponding transaction data entry for the above metadata type. */ + const specTransactionDataEntry = { + type: SCA_PAYMENT_TYPE, + credential_ids: ['payment_sca_01'], + transaction_data_hashes_alg: ['sha-256'], + payload: { + amount: '250.00', + currency: 'EUR', + beneficiary_name: 'Alice Wonderland', + beneficiary_iban: 'DE89370400440532013000', + nonce: 'abc123-random-nonce', + }, + } + + it('credential metadata parses through zCredentialMetadata', () => { + const result = zCredentialMetadata.parse(specCredentialMetadata) + assert.ok(result.display) + assert.strictEqual(result.display?.[0].name, 'Payment SCA Attestation') + assert.ok(result.transaction_data_types) + assert.ok(result.transaction_data_types?.[SCA_PAYMENT_TYPE]) + }) + + it('credential metadata parses through zScaCredentialMetadata', () => { + const result = zScaCredentialMetadata.parse(specCredentialMetadata) + assert.ok(result.transaction_data_types[SCA_PAYMENT_TYPE]) + }) + + it('isScaAttestationMetadata identifies it as SCA', () => { + assert.strictEqual(isScaAttestationMetadata(specCredentialMetadata), true) + }) + + it('transaction data type entry parses through zTransactionDataType', () => { + const typeEntry = specCredentialMetadata.transaction_data_types[SCA_PAYMENT_TYPE] + const result = zTransactionDataType.parse(typeEntry) + assert.strictEqual(result.claims.length, 5) + assert.ok(result.ui_labels.affirmative_action_label) + }) + + it('transaction data entry parses through zScaTransactionDataEntry', () => { + const result = zScaTransactionDataEntry.parse(specTransactionDataEntry) + assert.strictEqual(result.type, SCA_PAYMENT_TYPE) + assert.strictEqual(result.credential_ids[0], 'payment_sca_01') + assert.deepStrictEqual(result.transaction_data_hashes_alg, ['sha-256']) + assert.strictEqual((result.payload as Record).amount, '250.00') + }) + + it('transaction data entry parses through the zTransactionDataEntry union', () => { + const result = zTransactionDataEntry.parse(specTransactionDataEntry) + assert.strictEqual(result.type, SCA_PAYMENT_TYPE) + }) +})