diff --git a/CHANGELOG.md b/CHANGELOG.md index df212a36c..3cbe37674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,12 +40,13 @@ Release 6.0.0 (unreleased): - Credential definitions: - Move `CredentialScheme` out of `ConstantIndex` - Provide type alias for `CredentialRepresentation` - - Introudce typed sub-interfaces of `CredentialScheme`: `VcJwtCredentialScheme`, `SdJwtCredentialScheme` and `IsoMdocCredentialScheme` + - Introduce typed sub-interfaces of `CredentialScheme`: `VcJwtCredentialScheme`, `SdJwtCredentialScheme` and `IsoMdocCredentialScheme` - That implies changes to `CredentialToBeIssued`, `IssuedCredential`, `StoreCredentialInput` and methods in `SubjectCredentialStore` - In `CredentialScheme` deprecate `claimNames` (list of strings), to be replaced with `claimDescriptions` (set of typed descriptions) - In `StoreEntry` deprecate property `scheme` and add suspending function `resolveScheme()` to replace it - - Add `UnknownCredentialScheme` so that the `scheme` property of several classes is never null + - Add `UnknownCredentialScheme` so that the `scheme` property in several methods and classes is not null - Import data classes and data element strings from credentials into this library for [EU PID](https://github.com/a-sit-plus/eu-pid-credential), [EU PID in SD-JWT](https://github.com/a-sit-plus/eu-pid-credential-sdjwt/) and [Mobile Driving Licence](https://github.com/a-sit-plus/mobile-driving-licence-credential/) + - Document usage of remote metadata retrieval - New modules: - `etsi-data-classes` implements list of trusted entities from [ETSI TS 119 602](https://www.etsi.org/deliver/etsi_ts/119600_119699/119602/01.01.01_60/ts_119602v010101p.pdf) - `sd-jwt-type-metadata` implements SD-JWT VC Type Metadata from [draft-ietf-oauth-sd-jwt-vc-16](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/): @@ -57,7 +58,7 @@ Release 6.0.0 (unreleased): - Update to [Signum 3.23.0](https://github.com/a-sit-plus/signum/releases/tag/3.23.0) - Update to Ktor 3.5.0 - Update Bouncy Castle 1.84 - - Update to kotlinx.coroutines 1.11.0 + - Update to `kotlinx.coroutines` 1.11.0 - Matrix testing Release 5.12.0: @@ -79,7 +80,7 @@ Release 5.12.0: - Change: Update DCQLClaimsQuery and DCQLCredentialQuery to OpenID4VP 1.0 - Change: Do not fail when only matching credentials without submitting a presentation - Allow issuance and verification of `IdentifierList` Revocation Mechanism - - Change: Don't send response on user initiated signature cancellation + - Change: Don't send response on user-initiated signature cancellation - BREAKING CHANGE: The result type from `verifyAuthnResponse`, `AuthnResponseResult` has been reworked to a data class - DCQL: Add custom credential types and proper satisfaction evaluation - Add: DCQL submission requirements validation @@ -207,7 +208,7 @@ Release 5.10.0: - Drop single `proof` in credential request - Support credential response encryption correctly, see changed API in `CredentialIssuer.credential()` - Correctly verify credential request regarding `credential_configuration_id` and `credential_identifiers` - - Support credential request encryption correctly, if metadata is set at Issuer + - Support credential request encryption correctly if metadata is set at Issuer - OpenID for Verifiable Presentations: - Update implementation to 1.0 from 2025-07-09 - Remove code elements deprecated in 5.9.0 @@ -376,11 +377,11 @@ Release 5.8.0: - In `SimpleAuthorizationService` deprecate constructor parameter `dataProvider`, use `authorize()` with `OAuth2LoadUserFun` instead - In `AuthorizationService` deprecate `authorize()` methods, adding `authorize()` with `OAuth2LoadUserFun` - Credential schemes: - - Provide fallback credential schemes, to be used when no matching scheme is registered with this library: + - Provide fallback credential schemes to be used when no matching scheme is registered with this library: - `SdJwtFallbackCredentialScheme` - `VcFallbackCredentialScheme` - `IsoMdocFallbackCredentialScheme` - - Note that these schemes are not resolved automatically, and need to be used explicitly in client applications + - Note that these schemes are not resolved automatically and need to be used explicitly in client applications - SD-JWT: - Add data class for [SD-JWT VC Type metadata](https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-10.html#name-sd-jwt-vc-type-metadata) in `SdJwtTypeMetadata` - Update signum to provide SD-JWT VC Type metadata in `vctm` in the header of a SD-JWT @@ -440,7 +441,7 @@ Release 5.7.0: - Replace type aliases with functional interfaces (providing named parameters in implementations) - Make cryptographic verification functions suspending - Fully integrated crypto functionality based on Signum 3.16.2. This carries over breaking changes: - - All debug-only kotlinx.serialization for cryptographic datatypes like certificates, public keys, etc. was removed + - All debug-only `kotlinx.serialization` for cryptographic datatypes like certificates, public keys, etc. was removed - This finally cleans up the RSAorHMAC - `SignatureAlgorithm.RSAorHMAC` is now properly split into `SignatureAlgorithm` and `MessageAuthenticationCode`. Both implement `DataIntegrityAlgorithm`. - This split also affects `JwsAlgorithm`, which now has subtypes: `Signature` and `MAC`. Hence, `JwsAlgorithm.ES256` -> `JwsAlgorithm.Signature.ES256` @@ -458,7 +459,7 @@ Release 5.7.0: - Remove `Validator.checkRevocationStatus` in favor of `Validator.checkCredentialFreshness` - Remove `Holder.StoredCredential.status` - Remove `Verifier.VerifyCredentialResult.Revoked` - - Add constructor parameter `Validator.acceptedTokenStatuses` to allow library client to define token statuses deemed valid + - Add constructor parameter `Validator.acceptedTokenStatuses` to allow library clients to define token statuses deemed valid - Add support for Digital Credentials API as defined in OID4VP draft 28 and ISO 18013-7 Annex C: - Implement `DCAPIRequest` for requests received via the Digital Credentials API, with implementations for OID4VP (`Oid4vpDCAPIRequest`), ISO 18013-7 Annex C (`IsoMdocRequest`) and a non-standardised preview protocol (`PreviewDCAPIRequest`) - New property of type `Oid4vpDCAPIRequest` for requests originating from the Digital Credentials API in `AuthorizationResponsePreparationState` @@ -466,7 +467,7 @@ Release 5.7.0: - New optional parameter `filterById` of type `String` in `Holder.matchInputDescriptorsAgainstCredentialStore`, `HolderAgent.getValidCredentialsByPriority` `HolderAgent.matchInputDescriptorsAgainstCredentialStore` `HolderAgent.matchDCQLQueryAgainstCredentialStore` to filter credentials by id - New method `SubjectCredentialStore.getDcApiId` to generate an id of type `String` for a credential - New optional property of type `DCAPIHandover` for `SessionTranscript` - - Return member of interface `AuthenticationResult` instead of `AuthenticationSuccess` as authorization response in `OpenId4VpWallet`. Can either be + - Return member of interface `AuthenticationResult` instead of `AuthenticationSuccess` as authorization response in `OpenId4VpWallet`: - `AuthenticationSuccess`: contains a `redirectUri` (same behaviour as in 5.6.x) - `AuthenticationForward`: contains the `authenticationResponseResult` for responses via the Digital Credentials API - Refactoring of ISO data classes: @@ -837,7 +838,7 @@ Release 5.2.0: - Remove `scopePresentationDefinitionRetriever` from `OidcSiopWallet` to keep implementation simple - Dependency Updates: - Signum 3.11.1 - - Kotlin 2.1.0 through Conventions 2.1.0+20241204 + - Kotlin 2.1.0 through Conventions 2.1.0+20241204 Release 5.1.0: - Drop ARIES protocol implementation, and the `vck-aries` artifact @@ -906,7 +907,7 @@ Release 5.0.0: - Remove binding method for `did:key`, as it was never completely implemented, but add binding method `jwk` for JSON Web Keys. - Rework interface of `WalletService` to make selecting the credential configuration by its ID more explicit - Support requesting issuance of credential using scope values - - Introudce `OAuth2Client` to extract creating authentication requests and token requests from OID4VCI `WalletService` + - Introduce `OAuth2Client` to extract creating authentication requests and token requests from OID4VCI `WalletService` - Refactor `SimpleAuthorizationService` to extract actual authentication and authorization into `AuthorizationServiceStrategy` - Implement JWE encryption with AES-CBC-HMAC algorithms - SIOPv2/OpenID4VP: Support requesting and receiving claims from different credentials, i.e. a combined presentation diff --git a/README.md b/README.md index e9fb66f8c..ae989bdc1 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,53 @@ As discovered in [#226](https://github.com/a-sit-plus/vck/issues/226), using the The actual credentials are provided as discrete artefacts and are maintained separately [over here](https://github.com/a-sit-plus/credentials-collection). It is fine to add credentials **and** VC-K to as project dependencies, e. g., to use a version of VC-K that is more recent than the one a certain credentials depends on. +### Registering credential schemes + +Credential schemes are derived from [SD-JWT Type Metadata](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) +documents and resolved through `AttributeIndex`. Register one or more `CredentialMetadataRegistry` instances once at +startup; on a lookup miss `AttributeIndex` consults them, builds the scheme, and caches it. Two registries coexist: +a `StaticCredentialMetadataRegistry` for documents **bundled in code** (offline, authoritative; preloaded so they win +on lookup), and a `RemoteCredentialMetadataRegistry` that **fetches documents over HTTP** for everything else. The +documents are hosted in [credentials-collection](https://github.com/a-sit-plus/credentials-collection). + +```kotlin +val base = "https://raw.githubusercontent.com/a-sit-plus/credentials-collection/main" + +// Bundled in code: EU PID (ISO), EU PID SD-JWT, mDL. The URL is the document's hosted copy (becomes schemaUri). +LibraryInitializer.registerCredentialMetadataRegistry( + StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + EuPidSdJwtMetadataDocument, EuPidMetadataDocument, MobileDrivingLicenceMetadataDocument, + ), + documentUrls = mapOf( + EuPidSdJwtMetadataDocument.first to EU_PID_SD_JWT_METADATA_URL, + EuPidMetadataDocument.first to EU_PID_METADATA_URL, + MobileDrivingLicenceMetadataDocument.first to MDL_METADATA_URL, + ), + ) +) + +// Fetched on demand: add one `vct -> URL` entry per published document. SD-JWT resolves directly (identifier == vct); +// ISO mDoc has no direct vct fallback, so its docType must be aliased to the document's vct. +LibraryInitializer.registerCredentialMetadataRegistry( + RemoteCredentialMetadataRegistry( + httpClient = httpClient, // your app's Ktor HttpClient + clock = Clock.System, + documentUrls = mutableMapOf( + SdJwtVcType("urn:eudi:ehic:1") to "$base/ehic.json", + SdJwtVcType("eu.europa.ec.av.1") to "$base/age-verification.json", + ), + aliases = mapOf( + CredentialMetadataLookup(ISO_MDOC, "eu.europa.ec.av.1") to SdJwtVcType("eu.europa.ec.av.1"), + ), + ) +) +``` + +ISO mDoc credentials with non-primitive values additionally need their CBOR/JSON value serializers registered from +code (e.g. `LibraryInitializer.registerCredentialSerializers(EuPidJsonValueEncoder, EuPidItemValueSerializerMap)`); +schemes whose values are all primitive (such as the all-boolean age verification) need none. + ## Limitations - Several parts of the W3C VC Data Model have not been fully implemented, i.e. everything around resolving cryptographic key material. diff --git a/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt b/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt index c0079f09a..e0f60855c 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt @@ -3,14 +3,16 @@ import at.asitplus.testballoon.matrix.MatrixTestDefaults import at.asitplus.wallet.eupid.EuPidItemValueSerializerMap import at.asitplus.wallet.eupid.EuPidJsonValueEncoder import at.asitplus.wallet.eupid.EuPidMetadataDocument +import at.asitplus.wallet.eupid.EU_PID_METADATA_URL import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtMetadataDocument +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_METADATA_URL +import at.asitplus.wallet.mdl.MDL_METADATA_URL import at.asitplus.wallet.lib.LibraryInitializer import at.asitplus.wallet.lib.data.StaticCredentialMetadataRegistry import at.asitplus.wallet.mdl.MobileDrivingLicenceItemValueSerializerMap import at.asitplus.wallet.mdl.MobileDrivingLicenceJsonValueEncoder import at.asitplus.wallet.mdl.MobileDrivingLicenceMetadataDocument import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRegistry -import at.asitplus.wallet.sdjwt.SdJwtVcType import de.infix.testBalloon.framework.core.TestSession import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -30,9 +32,9 @@ class TestConfig : TestSession( MobileDrivingLicenceMetadataDocument, ), documentUrls = mapOf( - SdJwtVcType(EuPidSdJwtMetadataDocument.first.string) to "https://example.com", - SdJwtVcType(EuPidMetadataDocument.first.string) to "https://example.com", - SdJwtVcType(MobileDrivingLicenceMetadataDocument.first.string) to "https://example.com", + EuPidSdJwtMetadataDocument.first to EU_PID_SD_JWT_METADATA_URL, + EuPidMetadataDocument.first to EU_PID_METADATA_URL, + MobileDrivingLicenceMetadataDocument.first to MDL_METADATA_URL, ) ) ) diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataResolutionTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataResolutionTest.kt new file mode 100644 index 000000000..b77163fb3 --- /dev/null +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataResolutionTest.kt @@ -0,0 +1,143 @@ +package at.asitplus.wallet.lib.ktor.openid + +import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.lib.agent.FixedTimeClock +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT +import at.asitplus.wallet.lib.data.CredentialMetadataLookup +import at.asitplus.wallet.lib.data.IsoMdocCredentialScheme +import at.asitplus.wallet.lib.data.SdJwtCredentialScheme +import at.asitplus.wallet.lib.data.toCredentialScheme +import at.asitplus.wallet.sdjwt.SdJwtVcType +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.http.* + +/** + * Verifies that SD-JWT Type Metadata documents hosted in the `credentials-collection` repository resolve through + * [RemoteCredentialMetadataRegistry]. The two JSON bodies below mirror `ehic.json` and `age-verification.json` from + * that repository; the [MockEngine] serves them at their configured URLs so the test stays hermetic (no live network). + */ +val RemoteCredentialMetadataResolutionTest by matrixSuite { + + val base = "https://raw.githubusercontent.com/a-sit-plus/credentials-collection/main" + val ehicUrl = "$base/ehic.json" + val ageVerificationUrl = "$base/age-verification.json" + + val ehicVct = SdJwtVcType("urn:eudi:ehic:1") + val ageVerificationVct = SdJwtVcType("eu.europa.ec.av.1") + + fun registry() = RemoteCredentialMetadataRegistry( + httpClient = HttpClient(MockEngine { request -> + when (request.url.toString()) { + ehicUrl -> respond( + content = EHIC_JSON, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.CacheControl, "max-age=60"), + ) + + ageVerificationUrl -> respond( + content = AGE_VERIFICATION_JSON, + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.CacheControl, "max-age=60"), + ) + + else -> respondError(HttpStatusCode.NotFound) + } + }), + clock = FixedTimeClock(0), + documentUrls = mutableMapOf( + ehicVct to ehicUrl, + ageVerificationVct to ageVerificationUrl, + ), + // ISO mDoc has no direct vct fallback, so the docType must be aliased to the document's vct. + aliases = mapOf( + CredentialMetadataLookup(ISO_MDOC, ageVerificationVct.string) to ageVerificationVct, + ), + ) + + "EHIC SD-JWT metadata resolves remotely" { + val entry = registry().findEntry(ehicVct.string, SD_JWT).shouldNotBeNull() + entry.metadata.vct shouldBe ehicVct + entry.loadedFrom shouldBe ehicUrl + + val scheme = entry.toCredentialScheme().shouldBeInstanceOf() + scheme.sdJwtType shouldBe ehicVct.string + scheme.schemaUri shouldBe ehicUrl + // All 13 claims parsed, including the nested issuing_authority.* / authentic_source.* objects, all mandatory. + scheme.claimDescriptions.size shouldBe 13 + scheme.claimDescriptions.count { it.mandatory == true } shouldBe 13 + } + + "Age verification ISO mDoc metadata resolves remotely through an alias" { + val entry = registry().findEntry(ageVerificationVct.string, ISO_MDOC).shouldNotBeNull() + entry.metadata.vct shouldBe ageVerificationVct + entry.loadedFrom shouldBe ageVerificationUrl + + val scheme = entry.toCredentialScheme().shouldBeInstanceOf() + scheme.isoDocType shouldBe ageVerificationVct.string + scheme.isoNamespace shouldBe ageVerificationVct.string + scheme.schemaUri shouldBe ageVerificationUrl + // 11 age-over predicates; only age_over_18 is mandatory. + scheme.claimDescriptions.size shouldBe 11 + scheme.claimDescriptions.count { it.mandatory == true } shouldBe 1 + } + + "unknown vct does not resolve" { + registry().findEntry("urn:eudi:unknown:1", SD_JWT).shouldBeNull() + } +} + +private val EHIC_JSON = """ +{ + "vct": "urn:eudi:ehic:1", + "name": "European Health Insurance Card (EHIC)", + "description": "European Health Insurance Card, issued as an SD-JWT VC.", + "vck": { "format": "dc+sd-jwt" }, + "claims": [ + { "path": ["issuing_country"], "mandatory": true, "sd": "allowed" }, + { "path": ["personal_administrative_number"], "mandatory": true, "sd": "allowed" }, + { "path": ["issuing_authority"], "mandatory": true, "sd": "allowed" }, + { "path": ["issuing_authority", "id"], "mandatory": true, "sd": "allowed" }, + { "path": ["issuing_authority", "name"], "mandatory": true, "sd": "allowed" }, + { "path": ["authentic_source"], "mandatory": true, "sd": "allowed" }, + { "path": ["authentic_source", "id"], "mandatory": true, "sd": "allowed" }, + { "path": ["authentic_source", "name"], "mandatory": true, "sd": "allowed" }, + { "path": ["document_number"], "mandatory": true, "sd": "allowed" }, + { "path": ["date_of_issuance"], "mandatory": true, "sd": "allowed" }, + { "path": ["date_of_expiry"], "mandatory": true, "sd": "allowed" }, + { "path": ["starting_date"], "mandatory": true, "sd": "allowed" }, + { "path": ["ending_date"], "mandatory": true, "sd": "allowed" } + ] +} +""".trimIndent() + +private val AGE_VERIFICATION_JSON = """ +{ + "vct": "eu.europa.ec.av.1", + "name": "Age Verification", + "description": "Age verification attestation, issued as an ISO/IEC 18013-5 mdoc.", + "vck": { + "format": "mso_mdoc", + "isoDocType": "eu.europa.ec.av.1", + "isoNamespace": "eu.europa.ec.av.1" + }, + "claims": [ + { "path": ["eu.europa.ec.av.1", "age_over_18"], "mandatory": true }, + { "path": ["eu.europa.ec.av.1", "age_over_12"] }, + { "path": ["eu.europa.ec.av.1", "age_over_13"] }, + { "path": ["eu.europa.ec.av.1", "age_over_14"] }, + { "path": ["eu.europa.ec.av.1", "age_over_16"] }, + { "path": ["eu.europa.ec.av.1", "age_over_21"] }, + { "path": ["eu.europa.ec.av.1", "age_over_25"] }, + { "path": ["eu.europa.ec.av.1", "age_over_60"] }, + { "path": ["eu.europa.ec.av.1", "age_over_62"] }, + { "path": ["eu.europa.ec.av.1", "age_over_65"] }, + { "path": ["eu.europa.ec.av.1", "age_over_68"] } + ] +} +""".trimIndent() diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMapping.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMapping.kt index 20b796b83..086eef91f 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMapping.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMapping.kt @@ -1,8 +1,11 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.openid.ClaimDescription import at.asitplus.openid.CredentialFormatEnum import at.asitplus.openid.CredentialFormatEnum.DC_SD_JWT import at.asitplus.openid.CredentialFormatEnum.JWT_VC +import at.asitplus.openid.OpenId4VciClaimsPathPointer +import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentString import at.asitplus.openid.OpenIdConstants.BINDING_METHOD_COSE_KEY import at.asitplus.openid.OpenIdConstants.BINDING_METHOD_JWK import at.asitplus.openid.OpenIdConstants.URN_TYPE_JWK_THUMBPRINT @@ -58,28 +61,38 @@ interface CredentialSchemeMapper { fun decodeFromCredentialIdentifier(input: String): Pair? } -fun CredentialScheme.toIsoMdocSupportedCredentialFormat(identifier: String): Pair = +fun IsoMdocCredentialScheme.toIsoMdocSupportedCredentialFormat(identifier: String): Pair = identifier to SupportedCredentialFormat.forIsoMdoc( scope = identifier, - docType = isoDocType!!, + docType = isoDocType, supportedBindingMethods = setOf(BINDING_METHOD_JWK, BINDING_METHOD_COSE_KEY), - isoClaims = claimDescriptions + // ISO mdoc claims must be namespace-qualified. Multi-format schemes (e.g. AtomicAttribute2023) share JSON-style + // claim descriptions across representations, so prefix the namespace unless the path already carries it (as + // metadata-derived ISO schemes do). + isoClaims = claimDescriptions.map { it.qualifiedWithIsoNamespace(isoNamespace) }.toSet() ) -fun CredentialScheme.toPlainJwtSupportedCredentialFormat(identifier: String): Pair = +private fun ClaimDescription.qualifiedWithIsoNamespace(isoNamespace: String): ClaimDescription { + val firstSegment = path.firstOrNull() + val alreadyQualified = firstSegment is OpenId4VciClaimsPathPointerSegmentString && firstSegment.string == isoNamespace + return if (alreadyQualified) this + else copy(path = OpenId4VciClaimsPathPointer(isoNamespace) + path) +} + +fun VcJwtCredentialScheme.toPlainJwtSupportedCredentialFormat(identifier: String): Pair = identifier to SupportedCredentialFormat.forVcJwt( scope = identifier, credentialDefinition = VcJwtCredentialDefinition( - types = setOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType!!), + types = setOf(VcDataModelConstants.VERIFIABLE_CREDENTIAL, vcType), ), supportedBindingMethods = setOf(BINDING_METHOD_JWK, URN_TYPE_JWK_THUMBPRINT), vcJwtClaims = claimDescriptions ) -fun CredentialScheme.toSdJwtSupportedCredentialFormat(identifier: String): Pair = +fun SdJwtCredentialScheme.toSdJwtSupportedCredentialFormat(identifier: String): Pair = identifier to SupportedCredentialFormat.forSdJwt( scope = identifier, - sdJwtVcType = sdJwtType!!, + sdJwtVcType = sdJwtType, supportedBindingMethods = setOf(BINDING_METHOD_JWK, URN_TYPE_JWK_THUMBPRINT), sdJwtClaims = claimDescriptions ) diff --git a/vck-openid/src/commonTest/kotlin/TestConfig.kt b/vck-openid/src/commonTest/kotlin/TestConfig.kt index b01b78d2d..b1a7a30c1 100644 --- a/vck-openid/src/commonTest/kotlin/TestConfig.kt +++ b/vck-openid/src/commonTest/kotlin/TestConfig.kt @@ -3,14 +3,16 @@ import at.asitplus.testballoon.matrix.MatrixTestDefaults import at.asitplus.wallet.eupid.EuPidItemValueSerializerMap import at.asitplus.wallet.eupid.EuPidJsonValueEncoder import at.asitplus.wallet.eupid.EuPidMetadataDocument +import at.asitplus.wallet.eupid.EU_PID_METADATA_URL import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtMetadataDocument +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_METADATA_URL +import at.asitplus.wallet.mdl.MDL_METADATA_URL import at.asitplus.wallet.lib.LibraryInitializer import at.asitplus.wallet.lib.data.StaticCredentialMetadataRegistry import at.asitplus.wallet.mdl.MobileDrivingLicenceItemValueSerializerMap import at.asitplus.wallet.mdl.MobileDrivingLicenceJsonValueEncoder import at.asitplus.wallet.mdl.MobileDrivingLicenceMetadataDocument import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRegistry -import at.asitplus.wallet.sdjwt.SdJwtVcType import de.infix.testBalloon.framework.core.TestSession import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -32,9 +34,9 @@ class TestConfig : TestSession( MobileDrivingLicenceMetadataDocument, ), documentUrls = mapOf( - SdJwtVcType(EuPidSdJwtMetadataDocument.first.string) to "https://example.com", - SdJwtVcType(EuPidMetadataDocument.first.string) to "https://example.com", - SdJwtVcType(MobileDrivingLicenceMetadataDocument.first.string) to "https://example.com", + EuPidSdJwtMetadataDocument.first to EU_PID_SD_JWT_METADATA_URL, + EuPidMetadataDocument.first to EU_PID_METADATA_URL, + MobileDrivingLicenceMetadataDocument.first to MDL_METADATA_URL, ) ) ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMappingTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMappingTest.kt index e36124770..0b6a83a13 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMappingTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/CredentialSchemeMappingTest.kt @@ -1,11 +1,17 @@ package at.asitplus.wallet.lib.oidvci +import at.asitplus.openid.ClaimDescription import at.asitplus.openid.CredentialFormatEnum +import at.asitplus.openid.OpenId4VciClaimsPathPointer import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* +import at.asitplus.wallet.lib.data.ExtractedIsoMdocCredentialScheme +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldNotContain import io.kotest.matchers.maps.shouldContainKey import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe val CredentialSchemeMappingTest by matrixSuite { @@ -33,6 +39,28 @@ val CredentialSchemeMappingTest by matrixSuite { mapper.decodeFromCredentialIdentifier(expectedKey) shouldBe Pair(AtomicAttribute2023, ISO_MDOC) } + test("AtomicAttribute ISO claims are namespace-qualified") { + val format = mapper.map(AtomicAttribute2023)[AtomicAttribute2023.isoDocType].shouldNotBeNull() + val paths = format.credentialMetadata?.claimDescription.shouldNotBeNull().map { it.path } + paths shouldContain + OpenId4VciClaimsPathPointer(AtomicAttribute2023.isoNamespace, AtomicAttribute2023.CLAIM_GIVEN_NAME) + // The un-qualified JSON-style path must not be advertised for the ISO mdoc representation. + paths shouldNotContain OpenId4VciClaimsPathPointer(AtomicAttribute2023.CLAIM_GIVEN_NAME) + } + + test("already namespace-qualified ISO claims are not double-prefixed") { + val namespace = "org.iso.18013.5.1" + val scheme = ExtractedIsoMdocCredentialScheme( + schemaUri = "https://example.com", + isoDocType = "org.iso.18013.5.1.mDL", + isoNamespace = namespace, + claimDescriptions = setOf(ClaimDescription(OpenId4VciClaimsPathPointer(namespace, "given_name"))), + ) + val (_, format) = scheme.toIsoMdocSupportedCredentialFormat("identifier") + format.credentialMetadata?.claimDescription.shouldNotBeNull().map { it.path } shouldBe + listOf(OpenId4VciClaimsPathPointer(namespace, "given_name")) + } + test("unknown scheme in plain JWT") { val key = "${randomString()}#${CredentialFormatEnum.JWT_VC.text}" mapper.decodeFromCredentialIdentifier(key).shouldBeNull() diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt index bd7c1e6e6..6866f40bb 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt @@ -23,11 +23,21 @@ import kotlinx.serialization.json.encodeToJsonElement "Replace with type metadata document", level = DeprecationLevel.ERROR ) -object EuPidScheme +object EuPidScheme { + @Deprecated( + "Replace with EuPidDataElements", + ReplaceWith("EuPidDataElements") + ) + object Attributes +} /** `eu.europa.ec.eudi.pid.1` */ const val EU_PID_DOCTYPE: String = "eu.europa.ec.eudi.pid.1" +/** Canonical hosted location of [EuPidMetadataDocument], used as the resolved scheme's `schemaUri`. */ +const val EU_PID_METADATA_URL: String = + "https://raw.githubusercontent.com/a-sit-plus/credentials-collection/main/eu-pid.json" + val EuPidMetadataDocument: Pair = SdJwtVcType("EuPid2023") to SdJwtTypeMetadataDocument( originalBytes = ByteArray(0), diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt index 86ced3fa1..02bf15f95 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt @@ -11,11 +11,21 @@ import at.asitplus.wallet.sdjwt.SdJwtVcType "Replace with type metadata document", level = DeprecationLevel.ERROR ) -object EuPidSdJwtScheme +object EuPidSdJwtScheme { + @Deprecated( + "Replace with EuPidSdJwtDataElements", + ReplaceWith("EuPidSdJwtDataElements") + ) + object SdJwtAttributes +} /** `urn:eudi:pid:1` */ const val EU_PID_SD_JWT_VCT: String = "urn:eudi:pid:1" +/** Canonical hosted location of [EuPidSdJwtMetadataDocument], used as the resolved scheme's `schemaUri`. */ +const val EU_PID_SD_JWT_METADATA_URL: String = + "https://raw.githubusercontent.com/a-sit-plus/credentials-collection/main/eu-pid-sdjwt.json" + val EuPidSdJwtMetadataDocument: Pair = SdJwtVcType(EU_PID_SD_JWT_VCT) to SdJwtTypeMetadataDocument( originalBytes = ByteArray(0), diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt index b82f3b0d6..9b9e779ac 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt @@ -20,6 +20,7 @@ import at.asitplus.wallet.lib.data.CredentialPresentation import at.asitplus.wallet.lib.data.CredentialPresentationRequest import at.asitplus.wallet.lib.data.CredentialToJsonConverter import at.asitplus.wallet.lib.data.KeyBindingJws +import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL import at.asitplus.wallet.lib.data.VerifiablePresentationJws import at.asitplus.wallet.lib.data.dif.PresentationExchangeInputEvaluator import at.asitplus.wallet.lib.data.dif.PresentationSubmissionValidator @@ -313,10 +314,23 @@ class HolderAgent( fallbackFormatHolder = fallbackFormatHolder, credentialClaimStructure = CredentialToJsonConverter.toJsonElement(credential), credentialFormat = credential.credentialFormat, - credentialScheme = credential.schemeIdentifier, + credentialScheme = credential.schemeIdentifierForMatching, pathAuthorizationValidator = pathAuthorizationValidator, ) + /** + * Scheme identifier used to match input descriptors. Store entries serialized before [StoreEntry.schemeIdentifier] + * was introduced keep it `null`, so fall back to the identifier carried by the credential itself (the mdoc + * docType, the SD-JWT `vct`, or the W3C VC type). Otherwise `MSO_MDOC` input descriptors keyed by docType would + * never match legacy entries. + */ + private val StoreEntry.schemeIdentifierForMatching: String? + get() = schemeIdentifier ?: when (this) { + is StoreEntry.Iso -> issuerSigned.issuerAuth.payload?.docType + is StoreEntry.SdJwt -> sdJwt.verifiableCredentialType + is StoreEntry.Vc -> vc.vc.type.firstOrNull { it != VERIFIABLE_CREDENTIAL } + } + override suspend fun matchDCQLQueryAgainstCredentialStoreV2( dcqlQuery: DCQLQuery, filterByIds: Collection?, diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt index 6d64d8900..f6515168c 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt @@ -10,21 +10,9 @@ import at.asitplus.openid.OAuth2AuthorizationServerMetadata import at.asitplus.openid.SupportedCredentialFormat import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer -import at.asitplus.wallet.lib.data.AttributeIndex +import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* -import at.asitplus.wallet.lib.data.CredentialScheme -import at.asitplus.wallet.lib.data.IsoMdocCredentialScheme -import at.asitplus.wallet.lib.data.IsoMdocFallbackCredentialScheme -import at.asitplus.wallet.lib.data.SdJwtCredentialScheme -import at.asitplus.wallet.lib.data.SdJwtFallbackCredentialScheme -import at.asitplus.wallet.lib.data.SelectiveDisclosureItem -import at.asitplus.wallet.lib.data.UnknownCredentialScheme import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL -import at.asitplus.wallet.lib.data.VcFallbackCredentialScheme -import at.asitplus.wallet.lib.data.VcJwtCredentialScheme -import at.asitplus.wallet.lib.data.VerifiableCredential -import at.asitplus.wallet.lib.data.VerifiableCredentialJws -import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import io.ktor.utils.io.core.toByteArray import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -114,7 +102,10 @@ interface SubjectCredentialStore { @SerialName("scheme-identifier") override val schemeIdentifier: String? = null, ) : StoreEntry { - @Deprecated("Use resolveScheme() instead to support fetching remote definitions") + @Deprecated( + "Use resolveScheme() instead to support fetching remote definitions", + ReplaceWith("resolveScheme()") + ) override val scheme: CredentialScheme get() = schemeIdentifier?.let { AttributeIndex.resolveAttributeType(it) } ?: vc.vc.type.firstOrNull { it != VERIFIABLE_CREDENTIAL } @@ -149,7 +140,10 @@ interface SubjectCredentialStore { @SerialName("scheme-identifier") override val schemeIdentifier: String? = null, ) : StoreEntry { - @Deprecated("Use resolveScheme() instead to support fetching remote definitions") + @Deprecated( + "Use resolveScheme() instead to support fetching remote definitions", + ReplaceWith("resolveScheme()") + ) override val scheme: CredentialScheme get() = schemeIdentifier?.let { AttributeIndex.resolveSdJwtAttributeType(it) } ?: AttributeIndex.resolveSdJwtAttributeType(sdJwt.verifiableCredentialType) @@ -176,7 +170,10 @@ interface SubjectCredentialStore { @SerialName("scheme-identifier") override val schemeIdentifier: String? = null, ) : StoreEntry { - @Deprecated("Use resolveScheme() instead to support fetching remote definitions") + @Deprecated( + "Use resolveScheme() instead to support fetching remote definitions", + ReplaceWith("resolveScheme()") + ) override val scheme: CredentialScheme get() = schemeIdentifier?.let { AttributeIndex.resolveIsoDoctype(it) } ?: issuerSigned.issuerAuth.payload?.docType?.let { AttributeIndex.resolveIsoDoctype(it) } @@ -186,6 +183,7 @@ interface SubjectCredentialStore { override suspend fun resolveScheme(): CredentialScheme = schemeIdentifier?.let { AttributeIndex.resolveIdentifier(it, ISO_MDOC) } ?: issuerSigned.issuerAuth.payload?.docType?.let { AttributeIndex.resolveIdentifier(it, ISO_MDOC) } + ?: issuerSigned.issuerAuth.payload?.docType?.let { IsoMdocFallbackCredentialScheme(it) } ?: UnknownCredentialScheme(ISO_MDOC) override val credentialFormat: CredentialFormatEnum = CredentialFormatEnum.MSO_MDOC diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt index ce9069ea1..050b986f4 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt @@ -1,19 +1,8 @@ package at.asitplus.wallet.lib.data -import at.asitplus.data.NonEmptyList.Companion.toNonEmptyList import at.asitplus.openid.ClaimDescription -import at.asitplus.openid.DisplayProperties import at.asitplus.openid.OpenId4VciClaimsPathPointer -import at.asitplus.openid.OpenId4VciClaimsPathPointerSegment -import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentIndex -import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentString import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* -import at.asitplus.wallet.sdjwt.CredentialFormatEnum -import at.asitplus.wallet.sdjwt.SdJwtTypeMetadata -import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformation -import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegment -import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentIndex -import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentName object ConstantIndex { @@ -51,19 +40,28 @@ object ConstantIndex { } @Suppress("DEPRECATION") - @Deprecated("Use type check for SdJwtCredentialScheme") + @Deprecated( + "Use type check for SdJwtCredentialScheme", + ReplaceWith("this is SdJwtCredentialScheme") + ) val at.asitplus.wallet.lib.data.CredentialScheme.supportsSdJwt get() = (this is SdJwtCredentialScheme) || (supportedRepresentations.contains(SD_JWT) && sdJwtType != null) @Suppress("DEPRECATION") - @Deprecated("Use type check for VcJwtCredentialScheme") + @Deprecated( + "Use type check for VcJwtCredentialScheme", + ReplaceWith("this is VcJwtCredentialScheme") + ) val at.asitplus.wallet.lib.data.CredentialScheme.supportsVcJwt get() = (this is VcJwtCredentialScheme) || (supportedRepresentations.contains(PLAIN_JWT) && vcType != null) @Suppress("DEPRECATION") - @Deprecated("Use type check for IsoMdocCredentialScheme") + @Deprecated( + "Use type check for IsoMdocCredentialScheme", + ReplaceWith("this is IsoMdocCredentialScheme") + ) val at.asitplus.wallet.lib.data.CredentialScheme.supportsIso get() = (this is IsoMdocCredentialScheme) || (supportedRepresentations.contains(ISO_MDOC) && isoNamespace != null && isoDocType != null) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index b2c4aab89..82f956af1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -32,6 +32,9 @@ object CredentialToJsonConverter { if (credential.schemeIdentifier != null) { put("type", JsonPrimitive(credential.schemeIdentifier)) } else { + // Legacy entries (serialized before schemeIdentifier existed) expose the full VC type array. A scalar + // `$.type` filter (see RequestOptions.toVcConstraint()) matches any element of it, so array-typed VC-JWT + // credentials still satisfy the type constraint. put("type", credential.vc.vc.type.toJsonElement()) } val vcAsJsonElement = joseCompliantSerializer.encodeToJsonElement(credential.vc.vc.credentialSubject) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/UnknownCredentialScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/UnknownCredentialScheme.kt index fe42c4e62..82d584dbf 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/UnknownCredentialScheme.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/UnknownCredentialScheme.kt @@ -1,5 +1,9 @@ package at.asitplus.wallet.lib.data +/** + * Fallback for any credential identifier that we don't recognize but still need to parse. + * May not be the ideal solution, but this prevents a lot of nullable return types and should make life for apps easier. + */ data class UnknownCredentialScheme(val representation: CredentialRepresentation) : CredentialScheme { override val schemaUri: String = "https://wallet.a-sit.at/schemas/1.0.0/unknown.json" override val supportedRepresentations: Collection = listOf(representation) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationExchangeInputEvaluator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationExchangeInputEvaluator.kt index 3ccfeb19b..a2baaa6fb 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationExchangeInputEvaluator.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/PresentationExchangeInputEvaluator.kt @@ -167,6 +167,13 @@ object PresentationExchangeInputEvaluator { } internal fun JsonElement.matchConstraints(filter: ConstraintFilter): Boolean { + // A scalar filter (const/pattern/enum) matches an array node when any element matches: VC-JWT credentials commonly + // carry `type` as an array, while verifiers filter `$.type` for a single type string. + if (this is JsonArray && filter.type != "array" && + (filter.const != null || filter.pattern != null || filter.enum != null) + ) { + return any { it.matchConstraints(filter) } + } if (!matchType(filter)) { return false } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt index 8b8b23f5a..2a7529aca 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt @@ -30,6 +30,10 @@ const val MDL_DOCTYPE: String = "org.iso.18013.5.1.mDL" /** `org.iso.18013.5.1` */ const val MDL_NAMESPACE: String = "org.iso.18013.5.1" +/** Canonical hosted location of [MobileDrivingLicenceMetadataDocument], used as the resolved scheme's `schemaUri`. */ +const val MDL_METADATA_URL: String = + "https://raw.githubusercontent.com/a-sit-plus/credentials-collection/main/mdl.json" + val MobileDrivingLicenceMetadataDocument: Pair = SdJwtVcType(MDL_DOCTYPE) to SdJwtTypeMetadataDocument( originalBytes = ByteArray(0), diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/LegacyIsoSchemeMatchingTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/LegacyIsoSchemeMatchingTest.kt new file mode 100644 index 000000000..be3bb13e1 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/LegacyIsoSchemeMatchingTest.kt @@ -0,0 +1,80 @@ +package at.asitplus.wallet.lib.agent + +import at.asitplus.dif.Constraint +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.DifInputDescriptor +import at.asitplus.jsonpath.core.NormalizedJsonPath +import at.asitplus.jsonpath.core.NormalizedJsonPathSegment.NameSegment +import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC +import at.asitplus.wallet.lib.data.rfc3986.toUri +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf + +/** + * Regression test: ISO mdoc store entries serialized before [SubjectCredentialStore.StoreEntry.schemeIdentifier] + * existed keep that field `null`. Input-descriptor matching must still derive the docType from `issuerAuth` so that + * `MSO_MDOC` input descriptors keyed by docType continue to match those legacy entries. + */ +val LegacyIsoSchemeMatchingTest by matrixSuite { + + suspend fun legacyIsoEntryWithoutSchemeIdentifier(): SubjectCredentialStore.StoreEntry.Iso { + val holderKeyMaterial = EphemeralKeyWithSelfSignedCert() + val issuer = IssuerAgent( + keyMaterial = EphemeralKeyWithSelfSignedCert(), + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default, + ) + val issued = issuer.issueCredential( + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, AtomicAttribute2023, ISO_MDOC) + .getOrThrow() + ).getOrThrow().shouldBeInstanceOf() + + @Suppress("DEPRECATION") + return SubjectCredentialStore.StoreEntry.Iso( + issuerSigned = issued.issuerSigned, + schemaUri = AtomicAttribute2023.schemaUri, + schemeIdentifier = null, // entry serialized before scheme-identifier was introduced + ) + } + + fun isoInputDescriptor(id: String) = DifInputDescriptor( + id = id, + constraints = Constraint( + fields = setOf( + ConstraintField( + path = listOf( + NormalizedJsonPath( + NameSegment(AtomicAttribute2023.isoNamespace), + NameSegment(CLAIM_GIVEN_NAME), + ).toString() + ) + ) + ) + ) + ) + + "legacy ISO entry without scheme identifier matches its docType input descriptor" { + val holder = HolderAgent(EphemeralKeyWithSelfSignedCert(), InMemorySubjectCredentialStore()) + val entry = legacyIsoEntryWithoutSchemeIdentifier() + + holder.evaluateInputDescriptorAgainstCredential( + inputDescriptor = isoInputDescriptor(AtomicAttribute2023.isoDocType), + credential = entry, + fallbackFormatHolder = null, + ) { true }.isSuccess shouldBe true + } + + "legacy ISO entry without scheme identifier is rejected for a mismatched docType" { + val holder = HolderAgent(EphemeralKeyWithSelfSignedCert(), InMemorySubjectCredentialStore()) + val entry = legacyIsoEntryWithoutSchemeIdentifier() + + holder.evaluateInputDescriptorAgainstCredential( + inputDescriptor = isoInputDescriptor("org.example.other.doctype"), + credential = entry, + fallbackFormatHolder = null, + ) { true }.isSuccess shouldBe false + } +} diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/LegacyVcTypeConstraintTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/LegacyVcTypeConstraintTest.kt new file mode 100644 index 000000000..9aa5ad258 --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/LegacyVcTypeConstraintTest.kt @@ -0,0 +1,88 @@ +package at.asitplus.wallet.lib.data + +import at.asitplus.dif.Constraint +import at.asitplus.dif.ConstraintField +import at.asitplus.dif.ConstraintFilter +import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.lib.agent.DummyCredentialDataProvider +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert +import at.asitplus.wallet.lib.agent.HolderAgent +import at.asitplus.wallet.lib.agent.InMemorySubjectCredentialStore +import at.asitplus.wallet.lib.agent.IssuerAgent +import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.agent.SubjectCredentialStore +import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT +import at.asitplus.wallet.lib.data.dif.PresentationExchangeInputEvaluator +import at.asitplus.wallet.lib.data.rfc3986.toUri +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put + +/** + * A scalar `$.type` filter (see [at.asitplus.wallet.lib.RequestOptions]) must match VC-JWT credentials whose `type` is + * an array — both legacy store entries (serialized before [SubjectCredentialStore.StoreEntry.schemeIdentifier] existed, + * so the full `type` array is exposed) and any VC carrying multiple types. + */ +val LegacyVcTypeConstraintTest by matrixSuite { + + fun typeConstraint(type: String) = Constraint( + fields = setOf( + ConstraintField( + path = listOf("$.type"), + filter = ConstraintFilter(type = "string", const = JsonPrimitive(type)), + ) + ) + ) + + "scalar type filter matches an entry of the VC type array" { + val credential = buildJsonObject { + put("type", JsonArray(listOf(JsonPrimitive("VerifiableCredential"), JsonPrimitive("AtomicAttribute2023")))) + } + PresentationExchangeInputEvaluator + .evaluateInputDescriptorConstraint(typeConstraint("AtomicAttribute2023"), credential) { true } + .isSuccess shouldBe true + } + + "scalar type filter does not match a type absent from the array" { + val credential = buildJsonObject { + put("type", JsonArray(listOf(JsonPrimitive("VerifiableCredential"), JsonPrimitive("AtomicAttribute2023")))) + } + PresentationExchangeInputEvaluator + .evaluateInputDescriptorConstraint(typeConstraint("SomeOtherCredential"), credential) { true } + .isSuccess shouldBe false + } + + "legacy VC entry without scheme identifier matches the VC type constraint" { + val holderKeyMaterial = EphemeralKeyWithSelfSignedCert() + val issuer = IssuerAgent( + keyMaterial = EphemeralKeyWithSelfSignedCert(), + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default, + ) + val holder = HolderAgent(holderKeyMaterial, InMemorySubjectCredentialStore()) + holder.storeCredential( + issuer.issueCredential( + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, AtomicAttribute2023, PLAIN_JWT) + .getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ).getOrThrow() + val legacyEntry = holder.getCredentials()!! + .filterIsInstance() + .single() + .copy(schemeIdentifier = null) // simulate an entry serialized before scheme-identifier existed + + val json = CredentialToJsonConverter.toJsonElement(legacyEntry).jsonObject + // The full type array is exposed; the scalar filter matches an entry of it. + json["type"].shouldBeInstanceOf() shouldContain JsonPrimitive(AtomicAttribute2023.vcType) + PresentationExchangeInputEvaluator + .evaluateInputDescriptorConstraint(typeConstraint(AtomicAttribute2023.vcType), json) { true } + .isSuccess shouldBe true + } +} diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/PresentationExchangeInputEvaluatorTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/PresentationExchangeInputEvaluatorTest.kt index a9abfc31f..f9920e608 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/PresentationExchangeInputEvaluatorTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/PresentationExchangeInputEvaluatorTest.kt @@ -77,11 +77,23 @@ val PresentationExchangeInputEvaluatorTest by matrixSuite { } } - test("array credential does not match constraint field with string filter and const") { + test("array credential matches constraint field with string filter and const when an element matches") { + // VC-JWT credentials commonly carry array-valued claims (e.g. `type`); a scalar const filter matches when + // any element matches. PresentationExchangeInputEvaluator.matchConstraintFieldPaths( constraintField = stringConstFilter(it.elementIdentifier, it.elementValue), credential = it.arrayCredential, pathAuthorizationValidator = { true } + ).apply { + shouldHaveSize(1) + } + } + + test("array credential does not match constraint field with string filter and non-matching const") { + PresentationExchangeInputEvaluator.matchConstraintFieldPaths( + constraintField = stringConstFilter(it.elementIdentifier, "value-not-present-in-array"), + credential = it.arrayCredential, + pathAuthorizationValidator = { true } ).apply { shouldBeEmpty() }