Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Release 6.0.0 (unreleased):
- 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
- 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/):
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
nodh marked this conversation as resolved.

// 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.
Expand Down
10 changes: 6 additions & 4 deletions vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SdJwtCredentialScheme>()
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<IsoMdocCredentialScheme>()
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()
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -63,9 +66,19 @@ fun CredentialScheme.toIsoMdocSupportedCredentialFormat(identifier: String): Pai
scope = identifier,
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()
)

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 CredentialScheme.toPlainJwtSupportedCredentialFormat(identifier: String): Pair<String, SupportedCredentialFormat> =
identifier to SupportedCredentialFormat.forVcJwt(
scope = identifier,
Expand Down
10 changes: 6 additions & 4 deletions vck-openid/src/commonTest/kotlin/TestConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ object EuPidScheme
/** `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, SdJwtTypeMetadataDocument> =
SdJwtVcType("EuPid2023") to SdJwtTypeMetadataDocument(
originalBytes = ByteArray(0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ object EuPidSdJwtScheme
/** `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, SdJwtTypeMetadataDocument> =
SdJwtVcType(EU_PID_SD_JWT_VCT) to SdJwtTypeMetadataDocument(
originalBytes = ByteArray(0),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, SdJwtTypeMetadataDocument> =
SdJwtVcType(MDL_DOCTYPE) to SdJwtTypeMetadataDocument(
originalBytes = ByteArray(0),
Expand Down
Loading