diff --git a/Package.swift b/Package.swift index ace50bba..a4363f1b 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0") ], targets: [ - .binaryTarget(name: "RustFramework", url: "https://github.com/spruceid/sprucekit-mobile/releases/download/0.14.2/RustFramework.xcframework.zip", checksum: "994bfeb98e8e65da1c8fbdcf01d2293a866c46504d9d111cfb402bcccae362a4"), + .binaryTarget(name: "RustFramework", path: "rust/MobileSdkRs/RustFramework.xcframework"), .target( name: "SpruceIDMobileSdkRs", dependencies: [ diff --git a/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/Credential.kt b/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/Credential.kt index 4cb08f09..f63fbef9 100644 --- a/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/Credential.kt +++ b/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/Credential.kt @@ -4,6 +4,7 @@ import com.spruceid.mobile.sdk.rs.Cwt import com.spruceid.mobile.sdk.rs.JsonVc import com.spruceid.mobile.sdk.rs.JwtVc import com.spruceid.mobile.sdk.rs.Mdoc +import com.spruceid.mobile.sdk.rs.IetfSdJwtVc import com.spruceid.mobile.sdk.rs.Vcdm2SdJwt import org.json.JSONException import org.json.JSONObject @@ -149,6 +150,30 @@ fun Vcdm2SdJwt.credentialClaimsFiltered(claimNames: List): JSONObject { return new } +/** + * Access the IETF SD-JWT VC credential (dc+sd-jwt). + */ +fun IetfSdJwtVc.credentialClaims(): JSONObject { + try { + return JSONObject(this.revealedClaimsAsJsonString()) + } catch (e: Error) { + print("failed to decode dc+sd-jwt data from UTF-8-encoded JSON") + return JSONObject() + } +} + +/** + * Access the specified claims from the IETF SD-JWT VC credential (dc+sd-jwt). + */ +fun IetfSdJwtVc.credentialClaimsFiltered(claimNames: List): JSONObject { + val old = this.credentialClaims() + val new = JSONObject() + for (name in claimNames) { + new.put(name, keyPathFinder(old, name.split(".").toMutableList())) + } + return new +} + private fun keyPathFinder(json: Any, path: MutableList): Any { try { val firstKey = path.first() diff --git a/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/CredentialPack.kt b/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/CredentialPack.kt index 18a6690b..42f63e53 100644 --- a/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/CredentialPack.kt +++ b/android/MobileSdk/src/main/java/com/spruceid/mobile/sdk/CredentialPack.kt @@ -3,6 +3,7 @@ package com.spruceid.mobile.sdk import android.util.Log import com.spruceid.mobile.sdk.rs.CredentialDecodingException import com.spruceid.mobile.sdk.rs.Cwt +import com.spruceid.mobile.sdk.rs.IetfSdJwtVc import com.spruceid.mobile.sdk.rs.IssuanceServiceClient import com.spruceid.mobile.sdk.rs.JsonVc import com.spruceid.mobile.sdk.rs.JwtVc @@ -61,6 +62,11 @@ class CredentialPack { } catch (_: Exception) { } + try { + return this.addDcSdJwt(IetfSdJwtVc.newFromCompactSdJwt(rawCredential)) + } catch (_: Exception) { + } + try { return this.addJwtVc(JwtVc.newFromCompactJws(rawCredential)) } catch (_: Exception) { @@ -165,6 +171,14 @@ class CredentialPack { return credentials } + /** + * Add an IETF SD-JWT VC (dc+sd-jwt) to the CredentialPack. + */ + fun addDcSdJwt(dcSdJwt: IetfSdJwtVc): List { + credentials.add(ParsedCredential.newDcSdJwt(dcSdJwt)) + return credentials + } + /** * Add a CWT to the CredentialPack. */ @@ -232,6 +246,9 @@ class CredentialPack { res[credentialId] = CredentialStatusList.UNDEFINED } } + credential.asDcSdJwt()?.let { + res[credentialId] = CredentialStatusList.UNDEFINED + } } return res } @@ -255,6 +272,7 @@ class CredentialPack { val jwtVc = credential.asJwtVc() val jsonVc = credential.asJsonVc() val sdJwt = credential.asSdJwt() + val dcSdJwt = credential.asDcSdJwt() val cwt = credential.asCwt() if (mdoc != null) { @@ -287,6 +305,12 @@ class CredentialPack { } else { sdJwt.credentialClaims() } + } else if (dcSdJwt != null) { + claims = if (claimNames.isNotEmpty()) { + dcSdJwt.credentialClaimsFiltered(claimNames) + } else { + dcSdJwt.credentialClaims() + } } else { var type: String try { diff --git a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemDetails.kt b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemDetails.kt index ea3fd399..55b07c45 100644 --- a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemDetails.kt +++ b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemDetails.kt @@ -40,6 +40,7 @@ fun GenericCredentialItemDetails( cred?.asJsonVc() != null || cred?.asSdJwt() != null || cred?.asMsoMdoc() != null || + cred?.asDcSdJwt() != null || cred?.asCwt() != null ) { it.second diff --git a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemListItem.kt b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemListItem.kt index 3f4d92b5..707bd9a3 100644 --- a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemListItem.kt +++ b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/credentials/genericCredentialItem/GenericCredentialItemListItem.kt @@ -40,7 +40,7 @@ import com.spruceid.mobilesdkexample.ui.theme.ColorBase300 import com.spruceid.mobilesdkexample.ui.theme.ColorStone600 import com.spruceid.mobilesdkexample.ui.theme.ColorStone950 import com.spruceid.mobilesdkexample.ui.theme.Inter -import com.spruceid.mobilesdkexample.utils.mdocDisplayName +import com.spruceid.mobilesdkexample.utils.credentialTypeDisplayName import com.spruceid.mobilesdkexample.utils.splitCamelCase import com.spruceid.mobilesdkexample.viewmodels.StatusListViewModel import org.json.JSONObject @@ -84,6 +84,7 @@ fun GenericCredentialListItemDescriptionFormatter( val credential = values.toList().firstNotNullOfOrNull { val cred = credentialPack.getCredentialById(it.first) val mdoc = cred?.asMsoMdoc() + val dcSdJwt = cred?.asDcSdJwt() try { if ( cred?.asJwtVc() != null || @@ -98,6 +99,8 @@ fun GenericCredentialListItemDescriptionFormatter( it.second.put("issuer", issuer) } it.second + } else if (dcSdJwt != null) { + it.second } else { null } @@ -126,6 +129,13 @@ fun GenericCredentialListItemDescriptionFormatter( } } + if (description.isBlank()) { + try { + description = credential?.getString("issuing_authority").toString() + } catch (_: Exception) { + } + } + Column { Text( @@ -152,7 +162,8 @@ private fun GenericCredentialListItemLeadingIconFormatter( if ( cred?.asJwtVc() != null || cred?.asJsonVc() != null || - cred?.asSdJwt() != null + cred?.asSdJwt() != null || + cred?.asDcSdJwt() != null ) { it.second } else { @@ -231,6 +242,7 @@ fun GenericCredentialListItem( val cred = credentialPack.getCredentialById(it.first) try { val mdoc = cred?.asMsoMdoc() + val dcSdJwt = cred?.asDcSdJwt() if ( cred?.asJwtVc() != null || cred?.asJsonVc() != null || @@ -238,7 +250,10 @@ fun GenericCredentialListItem( ) { it.second } else if (mdoc != null) { - it.second.put("name", mdocDisplayName(mdoc.doctype())) + it.second.put("name", credentialTypeDisplayName(mdoc.doctype())) + it.second + } else if (dcSdJwt != null) { + it.second.put("name", credentialTypeDisplayName(dcSdJwt.vct())) it.second } else { null @@ -303,7 +318,7 @@ fun GenericCredentialListItem( } } }, - descriptionKeys = listOf("description", "issuer"), + descriptionKeys = listOf("description", "issuer", "issuing_authority"), descriptionFormatter = { values -> if (descriptionFormatter != null) { descriptionFormatter.invoke( diff --git a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/utils/Utils.kt b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/utils/Utils.kt index 3a73f6ce..494282df 100644 --- a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/utils/Utils.kt +++ b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/utils/Utils.kt @@ -13,6 +13,7 @@ import androidx.core.content.ContextCompat import com.spruceid.mobile.sdk.CredentialPack import com.spruceid.mobile.sdk.CredentialsViewModel import com.spruceid.mobile.sdk.rs.Cwt +import com.spruceid.mobile.sdk.rs.IetfSdJwtVc import com.spruceid.mobile.sdk.rs.JsonVc import com.spruceid.mobile.sdk.rs.JwtVc import com.spruceid.mobile.sdk.rs.Mdoc @@ -168,6 +169,12 @@ fun addCredential(credentialPack: CredentialPack, rawCredential: String): Creden } catch (_: Exception) { } + try { + credentialPack.addDcSdJwt(IetfSdJwtVc.newFromCompactSdJwt(rawCredential)) + return credentialPack + } catch (_: Exception) { + } + try { credentialPack.addJwtVc(JwtVc.newFromCompactJws(rawCredential)) return credentialPack @@ -267,6 +274,7 @@ fun getCredentialIdTitleAndIssuer( claims.entries.firstNotNullOf { claim -> val c = credentialPack.getCredentialById(claim.key) val mdoc = c?.asMsoMdoc() + val dcSdJwt = c?.asDcSdJwt() if ( c?.asSdJwt() != null || c?.asJwtVc() != null || @@ -278,26 +286,44 @@ fun getCredentialIdTitleAndIssuer( if (issuer != null && issuer.toString().isNotBlank()) { claim.value.put("issuer", issuer) } - val title = mdocDisplayName(mdoc.doctype()) + val title = credentialTypeDisplayName(mdoc.doctype()) claim.value.put("name", title) claim + } else if (dcSdJwt != null) { + val issuer = claim.value.opt("issuing_authority") + if (issuer != null && issuer.toString().isNotBlank()) { + claim.value.put("issuer", issuer) + } + claim.value.put("name", credentialTypeDisplayName(dcSdJwt.vct())) + claim } else { null } } } - // Mdoc - if (credential?.asMsoMdoc() != null || cred.equals(null)) { - cred = claims.entries.firstNotNullOf { claim -> - val mdoc = credentialPack.getCredentialById(claim.key)?.asMsoMdoc() + // dc+sd-jwt: use vct for display name + if (credential?.asDcSdJwt() != null || cred == null) { + cred = claims.entries.firstNotNullOfOrNull { claim -> + val dcSdJwt = credentialPack.getCredentialById(claim.key)?.asDcSdJwt() ?: return@firstNotNullOfOrNull null val issuer = claim.value.opt("issuing_authority") if (issuer != null && issuer.toString().isNotBlank()) { claim.value.put("issuer", issuer) } - val title = mdoc?.let { mdocDisplayName(it.doctype()) } ?: "" - claim.value.put("name", title) + claim.value.put("name", credentialTypeDisplayName(dcSdJwt.vct())) claim - } + } ?: cred + } + // Mdoc: use doctype for display name + if (credential?.asMsoMdoc() != null || cred == null) { + cred = claims.entries.firstNotNullOfOrNull { claim -> + val mdoc = credentialPack.getCredentialById(claim.key)?.asMsoMdoc() ?: return@firstNotNullOfOrNull null + val issuer = claim.value.opt("issuing_authority") + if (issuer != null && issuer.toString().isNotBlank()) { + claim.value.put("issuer", issuer) + } + claim.value.put("name", credentialTypeDisplayName(mdoc.doctype())) + claim + } ?: cred } val credentialKey = cred.key @@ -361,10 +387,11 @@ fun credentialPackHasMdoc(credentialPack: CredentialPack): Boolean { return false } -// MARK: - Mdoc Display Name Mapping +// MARK: - Credential Type Display Name Mapping -/** Known mdoc doctype to display name mappings. */ -private val mdocDoctypeDisplayNames = mapOf( +/** Known credential type identifier to display name mappings. + * Works for both mdoc doctypes and dc+sd-jwt vct values (shared namespace). */ +private val knownCredentialTypeDisplayNames = mapOf( "org.iso.18013.5.1.mDL" to "Mobile Driver's License", "org.iso.23220.photoID.1" to "Photo ID", "org.iso.7367.1.mVRC" to "Mobile Vehicle Registration Certificate", @@ -377,18 +404,19 @@ private val mdocDoctypeDisplayNames = mapOf( ) /** - * Returns a human-readable display name for the given mdoc doctype. - * Falls back to generating a readable name from the doctype string if unknown. + * Returns a human-readable display name for the given credential type identifier. + * Works for both mdoc doctypes and dc+sd-jwt vct values. + * Falls back to generating a readable name from the identifier if unknown. */ -fun mdocDisplayName(doctype: String): String { - return mdocDoctypeDisplayNames[doctype] ?: humanizeDoctype(doctype) +fun credentialTypeDisplayName(typeIdentifier: String): String { + return knownCredentialTypeDisplayNames[typeIdentifier] ?: humanizeTypeIdentifier(typeIdentifier) } /** - * Generates a human-readable name from an unknown doctype. + * Generates a human-readable name from an unknown type identifier. * Example: "eu.europa.ec.eudi.hiid.1" -> "Hiid" */ -private fun humanizeDoctype(doctype: String): String { +private fun humanizeTypeIdentifier(doctype: String): String { val components = doctype.split(".") if (components.size < 2) return doctype @@ -403,15 +431,6 @@ private fun humanizeDoctype(doctype: String): String { .replace("_", " ") .split(" ") .joinToString(" ") { word -> - word.replaceFirstChar { it.uppercaseChar() }.drop(1).lowercase() + - word.firstOrNull()?.uppercaseChar()?.toString().orEmpty() - }.let { - // Fix: properly capitalize first letter of each word - meaningfulComponent - .replace("_", " ") - .split(" ") - .joinToString(" ") { word -> - word.lowercase().replaceFirstChar { it.uppercaseChar() } - } + word.lowercase().replaceFirstChar { it.uppercaseChar() } } } diff --git a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierMDocResultView.kt b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierMDocResultView.kt index 326849fc..22f7a66b 100644 --- a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierMDocResultView.kt +++ b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/verifier/VerifierMDocResultView.kt @@ -35,7 +35,7 @@ import com.spruceid.mobilesdkexample.ui.theme.Inter import com.spruceid.mobilesdkexample.utils.ErrorToast import com.spruceid.mobilesdkexample.utils.SimpleAlertDialog import com.spruceid.mobilesdkexample.utils.WarningToast -import com.spruceid.mobilesdkexample.utils.mdocDisplayName +import com.spruceid.mobilesdkexample.utils.credentialTypeDisplayName @Composable fun VerifierMDocResultView( @@ -48,7 +48,7 @@ fun VerifierMDocResultView( logVerification: (String, String, String) -> Unit, ) { val mdoc by remember { mutableStateOf(convertToJson(result)) } - val title = mdocDisplayName(docTypes.firstOrNull() ?: "") + val title = credentialTypeDisplayName(docTypes.firstOrNull() ?: "") var issuer by remember { mutableStateOf(null) } LaunchedEffect(Unit) { diff --git a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleMdocOID4VPView.kt b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleMdocOID4VPView.kt index 8e0224d4..719deb2b 100644 --- a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleMdocOID4VPView.kt +++ b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleMdocOID4VPView.kt @@ -70,7 +70,7 @@ import com.spruceid.mobilesdkexample.ui.theme.Inter import com.spruceid.mobilesdkexample.utils.activityHiltViewModel import com.spruceid.mobilesdkexample.utils.getCredentialIdTitleAndIssuer import com.spruceid.mobilesdkexample.utils.getCurrentSqlDate -import com.spruceid.mobilesdkexample.utils.mdocDisplayName +import com.spruceid.mobilesdkexample.utils.credentialTypeDisplayName import com.spruceid.mobilesdkexample.viewmodels.CredentialPacksViewModel import com.spruceid.mobilesdkexample.viewmodels.WalletActivityLogsViewModel import kotlinx.coroutines.Dispatchers @@ -560,7 +560,7 @@ fun MdocSelectorItem( ) ) Text( - text = mdocDisplayName(doctype ?: ""), + text = credentialTypeDisplayName(doctype ?: ""), fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, diff --git a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleOID4VPView.kt b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleOID4VPView.kt index 85902625..6ef14e81 100644 --- a/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleOID4VPView.kt +++ b/android/Showcase/src/main/java/com/spruceid/mobilesdkexample/wallet/HandleOID4VPView.kt @@ -75,7 +75,7 @@ import com.spruceid.mobilesdkexample.utils.Toast import com.spruceid.mobilesdkexample.utils.activityHiltViewModel import com.spruceid.mobilesdkexample.utils.getCredentialIdTitleAndIssuer import com.spruceid.mobilesdkexample.utils.getCurrentSqlDate -import com.spruceid.mobilesdkexample.utils.mdocDisplayName +import com.spruceid.mobilesdkexample.utils.credentialTypeDisplayName import com.spruceid.mobilesdkexample.utils.removeUnderscores import com.spruceid.mobilesdkexample.utils.splitCamelCase import com.spruceid.mobilesdkexample.utils.trustedDids @@ -562,28 +562,34 @@ fun CredentialSelector( fun getCredentialTitle(credential: PresentableCredential): String { try { - credentialClaims[credential.asParsedCredential().id()]?.getString("name").let { - return it.toString() - } + credentialClaims[credential.asParsedCredential().id()]?.getString("name") + ?.takeIf { it.isNotBlank() }?.let { return it } } catch (_: Exception) { } try { - credentialClaims[credential.asParsedCredential().id()]?.getJSONArray("type").let { - for (i in 0 until it!!.length()) { + credentialClaims[credential.asParsedCredential().id()]?.getJSONArray("type")?.let { + for (i in 0 until it.length()) { if (it.get(i).toString() != "VerifiableCredential") { return it.get(i).toString().splitCamelCase() } } - return "" } } catch (_: Exception) { } - // For mdocs, use mdocDisplayName for proper display + // For mdocs, use doctype for display name try { credential.asParsedCredential().asMsoMdoc()?.let { mdoc -> - return mdocDisplayName(mdoc.doctype()) + return credentialTypeDisplayName(mdoc.doctype()) + } + } catch (_: Exception) { + } + + // For dc+sd-jwt, use vct for display name + try { + credential.asParsedCredential().asDcSdJwt()?.let { dcSdJwt -> + return credentialTypeDisplayName(dcSdJwt.vct()) } } catch (_: Exception) { } diff --git a/ios/MobileSdk/Sources/MobileSdk/Credential.swift b/ios/MobileSdk/Sources/MobileSdk/Credential.swift index eaf87b59..0907aa71 100644 --- a/ios/MobileSdk/Sources/MobileSdk/Credential.swift +++ b/ios/MobileSdk/Sources/MobileSdk/Credential.swift @@ -165,3 +165,23 @@ public extension Vcdm2SdJwt { } } } + +public extension IetfSdJwtVc { + /// Access the IETF SD-JWT VC decoded credential + func credentialClaims() -> [String: GenericJSON] { + guard let jsonString = try? revealedClaimsAsJsonString(), + let data = jsonString.data(using: .utf8), + let json = try? JSONDecoder().decode(GenericJSON.self, from: data), + let object = json.dictValue else { + return [:] + } + return object + } + + /// Access the specified claims from the IETF SD-JWT VC credential. + func credentialClaims(containing claimNames: [String]) -> [String: GenericJSON] { + credentialClaims().filter { key, _ in + claimNames.contains(key) + } + } +} diff --git a/ios/MobileSdk/Sources/MobileSdk/CredentialPack.swift b/ios/MobileSdk/Sources/MobileSdk/CredentialPack.swift index 49070ed1..741bf0af 100644 --- a/ios/MobileSdk/Sources/MobileSdk/CredentialPack.swift +++ b/ios/MobileSdk/Sources/MobileSdk/CredentialPack.swift @@ -52,6 +52,10 @@ public class CredentialPack { sdJwt: Vcdm2SdJwt.newFromCompactSdJwt(input: rawCredential) ) { return credentials + } else if let credentials = try? addDcSdJwt( + dcSdJwt: IetfSdJwtVc.newFromCompactSdJwt(input: rawCredential) + ) { + return credentials } else if let credentials = try? addCwt( cwt: Cwt.newFromBase10(payload: rawCredential) ) { @@ -138,6 +142,11 @@ public class CredentialPack { return credentials } + public func addDcSdJwt(dcSdJwt: IetfSdJwtVc) -> [ParsedCredential] { + credentials.append(ParsedCredential.newDcSdJwt(dcSdJwt: dcSdJwt)) + return credentials + } + #if canImport(IdentityDocumentServices) @available(iOS 26.0, *) private func addMDocToIDProvider(mdoc: Mdoc) async throws { @@ -291,6 +300,9 @@ public class CredentialPack { } else { res[credentialId] = CredentialStatusList.unknown } + } else if credential.asDcSdJwt() != nil { + // IETF SD-JWT VC (dc+sd-jwt) - status checking not yet implemented + res[credentialId] = CredentialStatusList.undefined } } @@ -353,6 +365,14 @@ public class CredentialPack { containing: claimNames ) } + } else if let dcSdJwt = credential.asDcSdJwt() { + if claimNames.isEmpty { + return dcSdJwt.credentialClaims() + } else { + return dcSdJwt.credentialClaims( + containing: claimNames + ) + } } else { var type: String do { diff --git a/ios/Showcase/Targets/AppUIKit/Sources/credentials/CredentialUtils.swift b/ios/Showcase/Targets/AppUIKit/Sources/credentials/CredentialUtils.swift index de039498..29dd4fe6 100644 --- a/ios/Showcase/Targets/AppUIKit/Sources/credentials/CredentialUtils.swift +++ b/ios/Showcase/Targets/AppUIKit/Sources/credentials/CredentialUtils.swift @@ -44,6 +44,9 @@ func addCredential(credentialPack: CredentialPack, rawCredential: String) async } else if (try? credentialPack.addSdJwt( sdJwt: Vcdm2SdJwt.newFromCompactSdJwt(input: rawCredential))) != nil { + } else if (try? credentialPack.addDcSdJwt( + dcSdJwt: IetfSdJwtVc.newFromCompactSdJwt(input: rawCredential))) != nil + { } else if (try? credentialPack.addCwt( cwt: Cwt.newFromBase10(payload: rawCredential))) != nil { @@ -144,7 +147,27 @@ func getCredentialIdTitleAndIssuer( || credential?.asSdJwt() != nil }) } - // Mdoc + // dc+sd-jwt: use vct for display name + if cred == nil || credential?.asDcSdJwt() != nil { + cred = + claims + .first(where: { + return credentialPack.get(credentialId: $0.key)?.asDcSdJwt() + != nil + }).map { claim in + var tmpClaim = claim + if let issuingAuthority = claim.value["issuing_authority"], + !issuingAuthority.toString().isEmpty { + tmpClaim.value["issuer"] = issuingAuthority + } + if let dcSdJwt = credentialPack.get(credentialId: claim.key)?.asDcSdJwt() { + tmpClaim.value["name"] = GenericJSON.string( + credentialTypeDisplayName(for: dcSdJwt.vct())) + } + return tmpClaim + } ?? cred + } + // Mdoc: use doctype for display name if credential?.asMsoMdoc() != nil || cred == nil { cred = claims @@ -153,15 +176,13 @@ func getCredentialIdTitleAndIssuer( != nil }).map { claim in var tmpClaim = claim - // Only set issuer if issuing_authority exists and has a valid value if let issuingAuthority = claim.value["issuing_authority"], !issuingAuthority.toString().isEmpty { tmpClaim.value["issuer"] = issuingAuthority } - // Use doctype-based display name if let mdoc = credentialPack.get(credentialId: claim.key)?.asMsoMdoc() { tmpClaim.value["name"] = GenericJSON.string( - mdocDisplayName(for: mdoc.doctype())) + credentialTypeDisplayName(for: mdoc.doctype())) } return tmpClaim } @@ -196,10 +217,12 @@ func getCredentialIdTitleAndIssuer( return (credentialKey, title ?? "", issuer) } -// MARK: - Mdoc Display Name Mapping +// MARK: - Credential Type Display Name Mapping -/// Known mdoc doctype to display name mappings. -private let mdocDoctypeDisplayNames: [String: String] = [ +/// Known credential type identifier to display name mappings. +/// Used for both mdoc doctypes and dc+sd-jwt vct values, since they share +/// the same namespace (e.g., EUDI types like "eu.europa.ec.eudi.hiid.1"). +private let knownCredentialTypeDisplayNames: [String: String] = [ "org.iso.18013.5.1.mDL": "Mobile Driver's License", "org.iso.23220.photoID.1": "Photo ID", "org.iso.7367.1.mVRC": "Mobile Vehicle Registration Certificate", @@ -211,20 +234,21 @@ private let mdocDoctypeDisplayNames: [String: String] = [ "eu.europa.ec.eudi.cor.1": "Certificate of Residence", ] -/// Returns a human-readable display name for the given mdoc doctype. -/// Falls back to generating a readable name from the doctype string if unknown. -func mdocDisplayName(for doctype: String) -> String { - if let knownName = mdocDoctypeDisplayNames[doctype] { +/// Returns a human-readable display name for a credential type identifier. +/// Works with mdoc doctypes and dc+sd-jwt vct values. +/// Falls back to generating a readable name from the identifier string if unknown. +func credentialTypeDisplayName(for typeIdentifier: String) -> String { + if let knownName = knownCredentialTypeDisplayNames[typeIdentifier] { return knownName } - return humanizeDoctype(doctype) + return humanizeTypeIdentifier(typeIdentifier) } -/// Generates a human-readable name from an unknown doctype. +/// Generates a human-readable name from an unknown type identifier. /// Example: "eu.europa.ec.eudi.hiid.1" -> "Hiid" -private func humanizeDoctype(_ doctype: String) -> String { - let components = doctype.split(separator: ".") - guard components.count >= 2 else { return doctype } +private func humanizeTypeIdentifier(_ typeIdentifier: String) -> String { + let components = typeIdentifier.split(separator: ".") + guard components.count >= 2 else { return typeIdentifier } // Get the second-to-last component (skip version number) let meaningfulComponent: String diff --git a/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemDetails.swift b/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemDetails.swift index c44ed6b0..e79b4ad7 100644 --- a/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemDetails.swift +++ b/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemDetails.swift @@ -20,6 +20,7 @@ struct GenericCredentialItemDetails: View { return credential?.asJwtVc() != nil || credential?.asJsonVc() != nil || credential?.asSdJwt() != nil + || credential?.asDcSdJwt() != nil || credential?.asMsoMdoc() != nil || credential?.asCwt() != nil }).map { $0.value } ?? [:] diff --git a/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemListItem.swift b/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemListItem.swift index ec803fa0..3e70394d 100644 --- a/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemListItem.swift +++ b/ios/Showcase/Targets/AppUIKit/Sources/credentials/genericCredentialItem/GenericCredentialItemListItem.swift @@ -28,17 +28,24 @@ struct GenericCredentialItemListItem: View { return credential?.asJwtVc() != nil || credential?.asJsonVc() != nil || credential?.asSdJwt() != nil + || credential?.asDcSdJwt() != nil || credential?.asMsoMdoc() != nil || credential?.asCwt() != nil }).map { - // Use doctype-based display name for mdocs - if let mdoc = credentialPack.get( + let credential = credentialPack.get( credentialId: $0.key - )?.asMsoMdoc() { + ) + if let mdoc = credential?.asMsoMdoc() { + var newValue = $0.value + newValue["name"] = GenericJSON.string( + credentialTypeDisplayName(for: mdoc.doctype()) + ) + return newValue + } else if let dcSdJwt = credential?.asDcSdJwt() { var newValue = $0.value newValue["name"] = GenericJSON.string( - mdocDisplayName(for: mdoc.doctype()) + credentialTypeDisplayName(for: dcSdJwt.vct()) ) return newValue } @@ -68,7 +75,7 @@ struct GenericCredentialItemListItem: View { } .padding(.leading, 12) }, - descriptionKeys: ["description", "issuer"], + descriptionKeys: ["description", "issuer", "issuing_authority"], descriptionFormatter: descriptionFormatter ?? { values in genericCredentialListItemDescriptionFormatter( credentialPack: credentialPack, @@ -104,15 +111,22 @@ struct GenericCredentialItemListItem: View { return credential?.asJwtVc() != nil || credential?.asJsonVc() != nil || credential?.asSdJwt() != nil + || credential?.asDcSdJwt() != nil || credential?.asMsoMdoc() != nil }).map { - // Use doctype-based display name for mdocs - if let mdoc = credentialPack.get( + let credential = credentialPack.get( credentialId: $0.key - )?.asMsoMdoc() { + ) + if let mdoc = credential?.asMsoMdoc() { + var newValue = $0.value + newValue["name"] = GenericJSON.string( + credentialTypeDisplayName(for: mdoc.doctype()) + ) + return newValue + } else if let dcSdJwt = credential?.asDcSdJwt() { var newValue = $0.value newValue["name"] = GenericJSON.string( - mdocDisplayName(for: mdoc.doctype()) + credentialTypeDisplayName(for: dcSdJwt.vct()) ) return newValue } @@ -172,7 +186,7 @@ struct GenericCredentialItemListItem: View { } .padding(.leading, 12) }, - descriptionKeys: ["description", "issuer"], + descriptionKeys: ["description", "issuer", "issuing_authority"], descriptionFormatter: descriptionFormatter ?? { values in genericCredentialListItemDescriptionFormatter( credentialPack: credentialPack, @@ -229,6 +243,7 @@ func genericCredentialListItemDescriptionFormatter( return credential?.asJwtVc() != nil || credential?.asJsonVc() != nil || credential?.asSdJwt() != nil + || credential?.asDcSdJwt() != nil || credential?.asMsoMdoc() != nil }).map { @@ -255,6 +270,8 @@ func genericCredentialListItemDescriptionFormatter( description = descriptionString } else if let issuerName = credential["issuer"]?.toString() { description = issuerName + } else if let issuingAuthority = credential["issuing_authority"]?.toString() { + description = issuingAuthority } return VStack(alignment: .leading, spacing: 12) { @@ -285,6 +302,7 @@ func genericCredentialListItemLeadingIconFormatter( return credential?.asJwtVc() != nil || credential?.asJsonVc() != nil || credential?.asSdJwt() != nil + || credential?.asDcSdJwt() != nil }).map { $0.value } ?? [:] let issuerImg = credential["issuer"]?.dictValue?["image"] diff --git a/ios/Showcase/Targets/AppUIKit/Sources/verifier/VerifierMdocResultView.swift b/ios/Showcase/Targets/AppUIKit/Sources/verifier/VerifierMdocResultView.swift index e26299a3..8c508fc7 100644 --- a/ios/Showcase/Targets/AppUIKit/Sources/verifier/VerifierMdocResultView.swift +++ b/ios/Showcase/Targets/AppUIKit/Sources/verifier/VerifierMdocResultView.swift @@ -35,7 +35,7 @@ struct VerifierMdocResultView: View { self.logVerification = logVerification let mdoc = convertToGenericJSON(map: result) self.mdoc = mdoc.dictValue ?? [:] - self.title = mdocDisplayName(for: docTypes.first ?? "") + self.title = credentialTypeDisplayName(for: docTypes.first ?? "") // Try to find issuing_authority from any namespace var foundIssuer = "" for (_, namespaceValue) in self.mdoc { diff --git a/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleMdocOID4VPView.swift b/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleMdocOID4VPView.swift index d9f1e2a8..c458817b 100644 --- a/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleMdocOID4VPView.swift +++ b/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleMdocOID4VPView.swift @@ -405,7 +405,7 @@ struct MdocSelectorItem: View { VStack { HStack { Toggle(isOn: $isChecked) { - Text(mdocDisplayName(for: doctype ?? "")) + Text(credentialTypeDisplayName(for: doctype ?? "")) .font( .customFont( font: .inter, style: .semiBold, size: .h3) diff --git a/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleOID4VPView.swift b/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleOID4VPView.swift index 140fb157..554728a4 100644 --- a/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleOID4VPView.swift +++ b/ios/Showcase/Targets/AppUIKit/Sources/wallet/HandleOID4VPView.swift @@ -528,7 +528,9 @@ struct CredentialSelector: View { } return title } else if let mdoc = credential.asParsedCredential().asMsoMdoc() { - return mdocDisplayName(for: mdoc.doctype()) + return credentialTypeDisplayName(for: mdoc.doctype()) + } else if let dcSdJwt = credential.asParsedCredential().asDcSdJwt() { + return credentialTypeDisplayName(for: dcSdJwt.vct()) } else { return "" } diff --git a/rust/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift b/rust/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift index cd93c589..926c57c2 100644 --- a/rust/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift +++ b/rust/MobileSdkRs/Sources/MobileSdkRs/mobile_sdk_rs.swift @@ -4366,6 +4366,233 @@ public func FfiConverterTypeIOSISO18013MobileDocumentRequestPresentmentRequest_l +/** + * IETF SD-JWT VC credential. + */ +public protocol IetfSdJwtVcProtocol: AnyObject, Sendable { + + /** + * Return the ID for the IetfSdJwtVc instance. + */ + func id() -> Uuid + + /** + * Return the key alias for the credential + */ + func keyAlias() -> KeyAlias? + + /** + * Return the revealed claims as a UTF-8 encoded JSON string. + */ + func revealedClaimsAsJsonString() throws -> String + + /** + * The type of this credential based on the vct claim. + */ + func type() -> CredentialType + + /** + * Return the Verifiable Credential Type (vct) claim. + */ + func vct() -> String + +} +/** + * IETF SD-JWT VC credential. + */ +open class IetfSdJwtVc: IetfSdJwtVcProtocol, @unchecked Sendable { + fileprivate let pointer: UnsafeMutableRawPointer! + + /// Used to instantiate a [FFIObject] without an actual pointer, for fakes in tests, mostly. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public struct NoPointer { + public init() {} + } + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + required public init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + // This constructor can be used to instantiate a fake object. + // - Parameter noPointer: Placeholder value so we can have a constructor separate from the default empty one that may be implemented for classes extending [FFIObject]. + // + // - Warning: + // Any object instantiated with this constructor cannot be passed to an actual Rust-backed object. Since there isn't a backing [Pointer] the FFI lower functions will crash. +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public init(noPointer: NoPointer) { + self.pointer = nil + } + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_mobile_sdk_rs_fn_clone_ietfsdjwtvc(self.pointer, $0) } + } + // No primary constructor declared for this class. + + deinit { + guard let pointer = pointer else { + return + } + + try! rustCall { uniffi_mobile_sdk_rs_fn_free_ietfsdjwtvc(pointer, $0) } + } + + +public static func fromCompactSdJwtWithIdAndKey(id: Uuid, input: String, keyAlias: KeyAlias)throws -> IetfSdJwtVc { + return try FfiConverterTypeIetfSdJwtVc_lift(try rustCallWithError(FfiConverterTypeIetfSdJwtVcError_lift) { + uniffi_mobile_sdk_rs_fn_constructor_ietfsdjwtvc_from_compact_sd_jwt_with_id_and_key( + FfiConverterTypeUuid_lower(id), + FfiConverterString.lower(input), + FfiConverterTypeKeyAlias_lower(keyAlias),$0 + ) +}) +} + + /** + * Create a new IetfSdJwtVc instance from a compact SD-JWT string. + */ +public static func newFromCompactSdJwt(input: String)throws -> IetfSdJwtVc { + return try FfiConverterTypeIetfSdJwtVc_lift(try rustCallWithError(FfiConverterTypeIetfSdJwtVcError_lift) { + uniffi_mobile_sdk_rs_fn_constructor_ietfsdjwtvc_new_from_compact_sd_jwt( + FfiConverterString.lower(input),$0 + ) +}) +} + + /** + * Create a new IetfSdJwtVc instance from a compact SD-JWT string with a provided key alias. + */ +public static func newFromCompactSdJwtWithKey(input: String, keyAlias: KeyAlias)throws -> IetfSdJwtVc { + return try FfiConverterTypeIetfSdJwtVc_lift(try rustCallWithError(FfiConverterTypeIetfSdJwtVcError_lift) { + uniffi_mobile_sdk_rs_fn_constructor_ietfsdjwtvc_new_from_compact_sd_jwt_with_key( + FfiConverterString.lower(input), + FfiConverterTypeKeyAlias_lower(keyAlias),$0 + ) +}) +} + + + + /** + * Return the ID for the IetfSdJwtVc instance. + */ +open func id() -> Uuid { + return try! FfiConverterTypeUuid_lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_ietfsdjwtvc_id(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return the key alias for the credential + */ +open func keyAlias() -> KeyAlias? { + return try! FfiConverterOptionTypeKeyAlias.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_ietfsdjwtvc_key_alias(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return the revealed claims as a UTF-8 encoded JSON string. + */ +open func revealedClaimsAsJsonString()throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeIetfSdJwtVcError_lift) { + uniffi_mobile_sdk_rs_fn_method_ietfsdjwtvc_revealed_claims_as_json_string(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * The type of this credential based on the vct claim. + */ +open func type() -> CredentialType { + return try! FfiConverterTypeCredentialType_lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_ietfsdjwtvc_type(self.uniffiClonePointer(),$0 + ) +}) +} + + /** + * Return the Verifiable Credential Type (vct) claim. + */ +open func vct() -> String { + return try! FfiConverterString.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_ietfsdjwtvc_vct(self.uniffiClonePointer(),$0 + ) +}) +} + + +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIetfSdJwtVc: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = IetfSdJwtVc + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> IetfSdJwtVc { + return IetfSdJwtVc(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: IetfSdJwtVc) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IetfSdJwtVc { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: IetfSdJwtVc, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIetfSdJwtVc_lift(_ pointer: UnsafeMutableRawPointer) throws -> IetfSdJwtVc { + return try FfiConverterTypeIetfSdJwtVc.lift(pointer) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIetfSdJwtVc_lower(_ value: IetfSdJwtVc) -> UnsafeMutableRawPointer { + return FfiConverterTypeIetfSdJwtVc.lower(value) +} + + + + + + public protocol InProcessRecordProtocol: AnyObject, Sendable { } @@ -8149,6 +8376,11 @@ public protocol ParsedCredentialProtocol: AnyObject, Sendable { */ func asCwt() -> Cwt? + /** + * Return the credential as an IETF SD-JWT VC (dc+sd-jwt format), if it is of that format. + */ + func asDcSdJwt() -> IetfSdJwtVc? + /** * Return the credential as a JsonVc if it is of that format. */ @@ -8165,7 +8397,7 @@ public protocol ParsedCredentialProtocol: AnyObject, Sendable { func asMsoMdoc() -> Mdoc? /** - * Return the credential as an SD-JWT, if it is of that format. + * Return the credential as an SD-JWT (VCDM2 format), if it is of that format. */ func asSdJwt() -> Vcdm2SdJwt? @@ -8273,6 +8505,17 @@ public static func newCwt(cwt: Cwt) -> ParsedCredential { FfiConverterTypeCwt_lower(cwt),$0 ) }) +} + + /** + * Construct a new `dc+sd-jwt` credential (IETF SD-JWT VC format). + */ +public static func newDcSdJwt(dcSdJwt: IetfSdJwtVc) -> ParsedCredential { + return try! FfiConverterTypeParsedCredential_lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_constructor_parsedcredential_new_dc_sd_jwt( + FfiConverterTypeIetfSdJwtVc_lower(dcSdJwt),$0 + ) +}) } public static func newFromJson(jsonString: String)throws -> ParsedCredential { @@ -8341,7 +8584,7 @@ public static func newMsoMdoc(mdoc: Mdoc) -> ParsedCredential { } /** - * Construct a new `sd_jwt_vc` credential. + * Construct a new `sd_jwt_vc` credential (VCDM2 format). */ public static func newSdJwt(sdJwtVc: Vcdm2SdJwt) -> ParsedCredential { return try! FfiConverterTypeParsedCredential_lift(try! rustCall() { @@ -8372,6 +8615,16 @@ open func asCwt() -> Cwt? { uniffi_mobile_sdk_rs_fn_method_parsedcredential_as_cwt(self.uniffiClonePointer(),$0 ) }) +} + + /** + * Return the credential as an IETF SD-JWT VC (dc+sd-jwt format), if it is of that format. + */ +open func asDcSdJwt() -> IetfSdJwtVc? { + return try! FfiConverterOptionTypeIetfSdJwtVc.lift(try! rustCall() { + uniffi_mobile_sdk_rs_fn_method_parsedcredential_as_dc_sd_jwt(self.uniffiClonePointer(),$0 + ) +}) } /** @@ -8405,7 +8658,7 @@ open func asMsoMdoc() -> Mdoc? { } /** - * Return the credential as an SD-JWT, if it is of that format. + * Return the credential as an SD-JWT (VCDM2 format), if it is of that format. */ open func asSdJwt() -> Vcdm2SdJwt? { return try! FfiConverterOptionTypeVCDM2SdJwt.lift(try! rustCall() { @@ -9059,7 +9312,7 @@ public protocol PresentableCredentialProtocol: AnyObject, Sendable { /** * Return if the credential supports selective disclosure - * For now only SdJwts are supported + * SD-JWT formats support selective disclosure */ func selectiveDisclosable() -> Bool @@ -9141,7 +9394,7 @@ open func isMdoc() -> Bool { /** * Return if the credential supports selective disclosure - * For now only SdJwts are supported + * SD-JWT formats support selective disclosure */ open func selectiveDisclosable() -> Bool { return try! FfiConverterBool.lift(try! rustCall() { @@ -16056,6 +16309,8 @@ public enum CredentialDecodingError: Swift.Error { ) case SdJwt(SdJwtError ) + case IetfSdJwtVc(IetfSdJwtVcError + ) case Cwt(CwtError ) case UnsupportedCredentialFormat(String @@ -16092,16 +16347,19 @@ public struct FfiConverterTypeCredentialDecodingError: FfiConverterRustBuffer { case 4: return .SdJwt( try FfiConverterTypeSdJwtError.read(from: &buf) ) - case 5: return .Cwt( + case 5: return .IetfSdJwtVc( + try FfiConverterTypeIetfSdJwtVcError.read(from: &buf) + ) + case 6: return .Cwt( try FfiConverterTypeCwtError.read(from: &buf) ) - case 6: return .UnsupportedCredentialFormat( + case 7: return .UnsupportedCredentialFormat( try FfiConverterString.read(from: &buf) ) - case 7: return .Serialization( + case 8: return .Serialization( try FfiConverterString.read(from: &buf) ) - case 8: return .Deserialization( + case 9: return .Deserialization( try FfiConverterString.read(from: &buf) ) @@ -16136,23 +16394,28 @@ public struct FfiConverterTypeCredentialDecodingError: FfiConverterRustBuffer { FfiConverterTypeSdJwtError.write(v1, into: &buf) - case let .Cwt(v1): + case let .IetfSdJwtVc(v1): writeInt(&buf, Int32(5)) + FfiConverterTypeIetfSdJwtVcError.write(v1, into: &buf) + + + case let .Cwt(v1): + writeInt(&buf, Int32(6)) FfiConverterTypeCwtError.write(v1, into: &buf) case let .UnsupportedCredentialFormat(v1): - writeInt(&buf, Int32(6)) + writeInt(&buf, Int32(7)) FfiConverterString.write(v1, into: &buf) case let .Serialization(v1): - writeInt(&buf, Int32(7)) + writeInt(&buf, Int32(8)) FfiConverterString.write(v1, into: &buf) case let .Deserialization(v1): - writeInt(&buf, Int32(8)) + writeInt(&buf, Int32(9)) FfiConverterString.write(v1, into: &buf) } @@ -16312,6 +16575,7 @@ public enum CredentialFormat { case jwtVcJsonLd case ldpVc case vcdm2SdJwt + case dcSdJwt case cwt case other(String ) @@ -16342,9 +16606,11 @@ public struct FfiConverterTypeCredentialFormat: FfiConverterRustBuffer { case 5: return .vcdm2SdJwt - case 6: return .cwt + case 6: return .dcSdJwt + + case 7: return .cwt - case 7: return .other(try FfiConverterString.read(from: &buf) + case 8: return .other(try FfiConverterString.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -16375,12 +16641,16 @@ public struct FfiConverterTypeCredentialFormat: FfiConverterRustBuffer { writeInt(&buf, Int32(5)) - case .cwt: + case .dcSdJwt: writeInt(&buf, Int32(6)) - case let .other(v1): + case .cwt: writeInt(&buf, Int32(7)) + + + case let .other(v1): + writeInt(&buf, Int32(8)) FfiConverterString.write(v1, into: &buf) } @@ -17747,6 +18017,129 @@ extension HttpClientError: Foundation.LocalizedError { +public enum IetfSdJwtVcError: Swift.Error { + + + + case InitError(String + ) + case SdJwtDecoding(String + ) + case InvalidSdJwt(String + ) + case Serialization(String + ) + case CredentialEncoding(String + ) + case MissingClaim(String + ) +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIetfSdJwtVcError: FfiConverterRustBuffer { + typealias SwiftType = IetfSdJwtVcError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IetfSdJwtVcError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InitError( + try FfiConverterString.read(from: &buf) + ) + case 2: return .SdJwtDecoding( + try FfiConverterString.read(from: &buf) + ) + case 3: return .InvalidSdJwt( + try FfiConverterString.read(from: &buf) + ) + case 4: return .Serialization( + try FfiConverterString.read(from: &buf) + ) + case 5: return .CredentialEncoding( + try FfiConverterString.read(from: &buf) + ) + case 6: return .MissingClaim( + try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: IetfSdJwtVcError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InitError(v1): + writeInt(&buf, Int32(1)) + FfiConverterString.write(v1, into: &buf) + + + case let .SdJwtDecoding(v1): + writeInt(&buf, Int32(2)) + FfiConverterString.write(v1, into: &buf) + + + case let .InvalidSdJwt(v1): + writeInt(&buf, Int32(3)) + FfiConverterString.write(v1, into: &buf) + + + case let .Serialization(v1): + writeInt(&buf, Int32(4)) + FfiConverterString.write(v1, into: &buf) + + + case let .CredentialEncoding(v1): + writeInt(&buf, Int32(5)) + FfiConverterString.write(v1, into: &buf) + + + case let .MissingClaim(v1): + writeInt(&buf, Int32(6)) + FfiConverterString.write(v1, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIetfSdJwtVcError_lift(_ buf: RustBuffer) throws -> IetfSdJwtVcError { + return try FfiConverterTypeIetfSdJwtVcError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIetfSdJwtVcError_lower(_ value: IetfSdJwtVcError) -> RustBuffer { + return FfiConverterTypeIetfSdJwtVcError.lower(value) +} + + +extension IetfSdJwtVcError: Equatable, Hashable {} + + + +extension IetfSdJwtVcError: Foundation.LocalizedError { + public var errorDescription: String? { + String(reflecting: self) + } +} + + + public enum InvalidClaims: Swift.Error { @@ -23042,6 +23435,30 @@ fileprivate struct FfiConverterOptionTypeCwt: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeIetfSdJwtVc: FfiConverterRustBuffer { + typealias SwiftType = IetfSdJwtVc? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeIetfSdJwtVc.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeIetfSdJwtVc.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -25935,6 +26352,21 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_method_iosiso18013mobiledocumentrequest_to_matches() != 17136) { return InitializationResult.apiChecksumMismatch } + if (uniffi_mobile_sdk_rs_checksum_method_ietfsdjwtvc_id() != 57442) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_ietfsdjwtvc_key_alias() != 39743) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_ietfsdjwtvc_revealed_claims_as_json_string() != 1813) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_ietfsdjwtvc_type() != 51355) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_method_ietfsdjwtvc_vct() != 63525) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_mobile_sdk_rs_checksum_method_inprogressrequest180137_matches() != 21157) { return InitializationResult.apiChecksumMismatch } @@ -26157,6 +26589,9 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_as_cwt() != 46037) { return InitializationResult.apiChecksumMismatch } + if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_as_dc_sd_jwt() != 17508) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_as_json_vc() != 62122) { return InitializationResult.apiChecksumMismatch } @@ -26166,7 +26601,7 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_as_mso_mdoc() != 54804) { return InitializationResult.apiChecksumMismatch } - if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_as_sd_jwt() != 23438) { + if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_as_sd_jwt() != 33528) { return InitializationResult.apiChecksumMismatch } if (uniffi_mobile_sdk_rs_checksum_method_parsedcredential_format() != 39112) { @@ -26229,7 +26664,7 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_method_presentablecredential_is_mdoc() != 17013) { return InitializationResult.apiChecksumMismatch } - if (uniffi_mobile_sdk_rs_checksum_method_presentablecredential_selective_disclosable() != 24142) { + if (uniffi_mobile_sdk_rs_checksum_method_presentablecredential_selective_disclosable() != 55127) { return InitializationResult.apiChecksumMismatch } if (uniffi_mobile_sdk_rs_checksum_method_readerapduhandoverdriver_process_rapdu() != 65181) { @@ -26478,6 +26913,15 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_constructor_iosiso18013mobiledocumentrequestpresentmentrequest_new() != 37016) { return InitializationResult.apiChecksumMismatch } + if (uniffi_mobile_sdk_rs_checksum_constructor_ietfsdjwtvc_from_compact_sd_jwt_with_id_and_key() != 12356) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_constructor_ietfsdjwtvc_new_from_compact_sd_jwt() != 39911) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_mobile_sdk_rs_checksum_constructor_ietfsdjwtvc_new_from_compact_sd_jwt_with_key() != 46850) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_mobile_sdk_rs_checksum_constructor_issuanceserviceclient_new() != 39224) { return InitializationResult.apiChecksumMismatch } @@ -26535,6 +26979,9 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_new_cwt() != 43883) { return InitializationResult.apiChecksumMismatch } + if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_new_dc_sd_jwt() != 44340) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_new_from_json() != 1837) { return InitializationResult.apiChecksumMismatch } @@ -26553,7 +27000,7 @@ private let initializationResult: InitializationResult = { if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_new_mso_mdoc() != 58058) { return InitializationResult.apiChecksumMismatch } - if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_new_sd_jwt() != 34266) { + if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_new_sd_jwt() != 13357) { return InitializationResult.apiChecksumMismatch } if (uniffi_mobile_sdk_rs_checksum_constructor_parsedcredential_parse_from_credential() != 15018) { diff --git a/rust/src/credential/format/ietf_sd_jwt_vc.rs b/rust/src/credential/format/ietf_sd_jwt_vc.rs new file mode 100644 index 00000000..44bfaf2d --- /dev/null +++ b/rust/src/credential/format/ietf_sd_jwt_vc.rs @@ -0,0 +1,506 @@ +//! This implements support for SD-JWT-based Verifiable Digital Credentials as defined in +//! [draft-ietf-oauth-sd-jwt-vc 14](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/14/). +use crate::{ + credential::{Credential, CredentialFormat}, + crypto::KeyAlias, + oid4vp::{ + error::OID4VPError, + permission_request::RequestedField, + presentation::{CredentialPresentation, PresentationOptions}, + }, + CredentialType, +}; + +use core::str; +use std::sync::Arc; + +use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +use openid4vp::core::{ + credential_format::ClaimFormatDesignation, dcql_query::DcqlCredentialQuery, + response::parameters::VpTokenItem, +}; +use ssi::{ + claims::{ + jws::{JwsSigner, JwsSignerInfo}, + jwt::AnyClaims, + sd_jwt::{KbJwtPayload, SdAlg, SdJwtBuf}, + SignatureError, + }, + JsonPointerBuf, +}; +use uuid::Uuid; + +pub const FORMAT_DC_SD_JWT: &str = "dc+sd-jwt"; + +/// IETF SD-JWT VC credential. +#[derive(Debug, uniffi::Object)] +pub struct IetfSdJwtVc { + pub(crate) id: Uuid, + pub(crate) key_alias: Option, + /// The revealed claims from the SD-JWT + pub(crate) claims: serde_json::Value, + /// The raw SD-JWT buffer + pub(crate) inner: SdJwtBuf, +} + +impl IetfSdJwtVc { + /// Return the revealed claims as a JSON value. + pub fn revealed_claims_as_json(&self) -> Result { + Ok(self.claims.clone()) + } + + /// Get the issuer claim. + pub fn issuer(&self) -> Option { + self.claims + .get("iss") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } +} + +#[uniffi::export] +impl IetfSdJwtVc { + /// Create a new IetfSdJwtVc instance from a compact SD-JWT string. + #[uniffi::constructor] + pub fn new_from_compact_sd_jwt(input: String) -> Result, IetfSdJwtVcError> { + let inner: SdJwtBuf = + SdJwtBuf::new(input).map_err(|e| IetfSdJwtVcError::InvalidSdJwt(format!("{e:?}")))?; + + let mut sd_jwt = IetfSdJwtVc::try_from(inner)?; + sd_jwt.key_alias = None; + + Ok(Arc::new(sd_jwt)) + } + + /// Create a new IetfSdJwtVc instance from a compact SD-JWT string with a provided key alias. + #[uniffi::constructor] + pub fn new_from_compact_sd_jwt_with_key( + input: String, + key_alias: KeyAlias, + ) -> Result, IetfSdJwtVcError> { + let inner: SdJwtBuf = + SdJwtBuf::new(input).map_err(|e| IetfSdJwtVcError::InvalidSdJwt(format!("{e:?}")))?; + + let mut sd_jwt = IetfSdJwtVc::try_from(inner)?; + sd_jwt.key_alias = Some(key_alias); + + Ok(Arc::new(sd_jwt)) + } + + #[uniffi::constructor] + pub fn from_compact_sd_jwt_with_id_and_key( + id: Uuid, + input: String, + key_alias: KeyAlias, + ) -> Result, IetfSdJwtVcError> { + let inner: SdJwtBuf = + SdJwtBuf::new(input).map_err(|e| IetfSdJwtVcError::InvalidSdJwt(format!("{e:?}")))?; + + let mut sd_jwt = IetfSdJwtVc::try_from((id, inner))?; + sd_jwt.key_alias = Some(key_alias); + + Ok(Arc::new(sd_jwt)) + } + + /// Return the ID for the IetfSdJwtVc instance. + pub fn id(&self) -> Uuid { + self.id + } + + /// Return the key alias for the credential + pub fn key_alias(&self) -> Option { + self.key_alias.clone() + } + + /// Return the Verifiable Credential Type (vct) claim. + pub fn vct(&self) -> String { + self.claims + .get("vct") + .and_then(|v| v.as_str()) + .expect("vct is validated during construction") + .to_string() + } + + /// The type of this credential based on the vct claim. + pub fn r#type(&self) -> CredentialType { + CredentialType(self.vct()) + } + + /// Return the revealed claims as a UTF-8 encoded JSON string. + pub fn revealed_claims_as_json_string(&self) -> Result { + serde_json::to_string(&self.claims) + .map_err(|e| IetfSdJwtVcError::Serialization(format!("{e:?}"))) + } +} + +impl IetfSdJwtVc { + /// Get all credential claims as JSON. + pub fn credential_claims(&self) -> std::collections::HashMap { + if let Some(obj) = self.claims.as_object() { + obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + } else { + std::collections::HashMap::new() + } + } +} + +impl IetfSdJwtVc { + /// Check if the credential satisfies a DCQL credential query. + pub fn satisfies_dcql_query(&self, credential_query: &DcqlCredentialQuery) -> bool { + // Check format + if credential_query.format() != &ClaimFormatDesignation::DcSdJwt { + return false; + } + + // Check vct if specified in meta + if let Some(vct_values) = credential_query + .meta() + .get("vct_values") + .and_then(|v| v.as_array()) + { + let credential_vct = self.vct(); + + let matches = vct_values.iter().any(|expected_vct| { + expected_vct + .as_str() + .map(|s| s == credential_vct) + .unwrap_or(false) + }); + + if !matches { + return false; + } + } + + true + } + + /// Return the requested fields for the credential, according to a DCQL credential query. + pub fn requested_fields_dcql( + &self, + credential_query: &DcqlCredentialQuery, + ) -> Vec> { + use openid4vp::core::dcql_query::DcqlCredentialClaimsQueryPath; + + let claims = credential_query.claims(); + let Some(claims) = claims else { + return vec![]; + }; + + claims + .iter() + .flat_map(|claim_query| { + let path = claim_query.path(); + let path_strings: Vec = path + .iter() + .filter_map(|p| match p { + DcqlCredentialClaimsQueryPath::String(s) => Some(s.clone()), + DcqlCredentialClaimsQueryPath::Integer(n) => Some(n.to_string()), + DcqlCredentialClaimsQueryPath::Null => None, // Skip null (wildcard) elements + }) + .collect(); + + // Try to get the value at this path + let value = self.get_value_at_path(path); + + Some(Arc::new(RequestedField::from_dcql_claims_with_name( + credential_query.id().to_string(), + path_strings.clone(), + value.map(|v| vec![v]).unwrap_or_default(), + Some(path_strings.join(".")), + ))) + }) + .collect() + } + + fn get_value_at_path( + &self, + path: &[openid4vp::core::dcql_query::DcqlCredentialClaimsQueryPath], + ) -> Option { + use openid4vp::core::dcql_query::DcqlCredentialClaimsQueryPath; + + let mut current = &self.claims; + for segment in path { + match segment { + DcqlCredentialClaimsQueryPath::String(key) => { + current = current.get(key)?; + } + DcqlCredentialClaimsQueryPath::Integer(index) => { + current = current.get(*index)?; + } + DcqlCredentialClaimsQueryPath::Null => { + // Null represents a wildcard; we can't traverse wildcards directly + return None; + } + } + } + Some(current.clone()) + } +} + +/// Adapter to use a [`PresentationSigner`] as a [`JwsSigner`] for KB-JWT signing. +struct PresentationJwsSigner<'a> { + signer: &'a dyn crate::oid4vp::presentation::PresentationSigner, +} + +impl JwsSigner for PresentationJwsSigner<'_> { + async fn fetch_info(&self) -> Result { + let algorithm = self + .signer + .algorithm() + .try_into() + .map_err(|e| SignatureError::other(format!("unsupported algorithm: {e:?}")))?; + Ok(JwsSignerInfo { + algorithm, + key_id: None, + }) + } + + async fn sign_bytes(&self, signing_bytes: &[u8]) -> Result, SignatureError> { + let signature = self + .signer + .sign(signing_bytes.to_vec()) + .await + .map_err(|e| SignatureError::other(format!("{e:?}")))?; + + // The native signer (iOS SecKey) may return DER-encoded signatures. + // JWS requires raw fixed-width R||S encoding for ECDSA. + crate::crypto::CryptoCurveUtils::secp256r1() + .ensure_raw_fixed_width_signature_encoding(signature) + .ok_or_else(|| SignatureError::other("failed to encode signature as raw R||S")) + } +} + +impl CredentialPresentation for IetfSdJwtVc { + type Credential = serde_json::Value; + type CredentialFormat = ClaimFormatDesignation; + type PresentationFormat = ClaimFormatDesignation; + + fn credential(&self) -> &Self::Credential { + &self.claims + } + + fn presentation_format(&self) -> Self::PresentationFormat { + ClaimFormatDesignation::DcSdJwt + } + + fn credential_format(&self) -> Self::CredentialFormat { + ClaimFormatDesignation::DcSdJwt + } + + /// Return the credential as a VpToken with Key Binding JWT. + async fn as_vp_token_item<'a>( + &self, + options: &'a PresentationOptions<'a>, + selected_fields: Option>, + ) -> Result { + // Build the SD-JWT with selective disclosure filtering. + let mut sd_jwt = if let Some(selected_fields) = selected_fields { + let selected_fields_pointers = selected_fields + .into_iter() + .map(|sfield| { + let segments: Vec = sfield + .split(',') + .map(|segment| { + let bytes = URL_SAFE + .decode(segment) + .map_err(|e| OID4VPError::JsonPathParse(e.to_string()))?; + str::from_utf8(&bytes) + .map(|s| s.to_string()) + .map_err(|e| OID4VPError::JsonPathParse(e.to_string())) + }) + .collect::, _>>()?; + + let pointer = format!("/{}", segments.join("/")); + JsonPointerBuf::new(pointer) + .map_err(|e| OID4VPError::JsonPathToPointer(e.to_string())) + }) + .collect::, _>>()?; + + self.inner + .decode_reveal::() + .map_err(|e| OID4VPError::VpTokenParse(e.to_string()))? + .retaining(&selected_fields_pointers) + .into_encoded() + } else { + self.inner.clone() + }; + + // Create and attach Key Binding JWT (KB-JWT). + let aud = options + .audience() + .ok_or_else(|| { + OID4VPError::VpTokenCreate("missing client_id for KB-JWT audience".into()) + })? + .clone(); + let nonce = options.nonce().clone(); + + let kb_payload = KbJwtPayload::new(aud, nonce, SdAlg::Sha256, &sd_jwt); + + let jws_signer = PresentationJwsSigner { + signer: options.signer.as_ref().as_ref(), + }; + + let kb_jwt = jws_signer + .sign(kb_payload) + .await + .map_err(|e| OID4VPError::VpTokenCreate(format!("KB-JWT signing failed: {e:?}")))?; + + sd_jwt.set_kb(&kb_jwt); + + Ok(VpTokenItem::String(sd_jwt.as_str().to_string())) + } +} + +impl TryFrom for IetfSdJwtVc { + type Error = IetfSdJwtVcError; + + fn try_from(value: SdJwtBuf) -> Result { + let revealed = value + .decode_reveal::() + .map_err(|e| IetfSdJwtVcError::SdJwtDecoding(format!("{e:?}")))?; + + let claims = serde_json::to_value(revealed.claims()) + .map_err(|e| IetfSdJwtVcError::Serialization(format!("{e:?}")))?; + + if claims.get("vct").and_then(|v| v.as_str()).is_none() { + return Err(IetfSdJwtVcError::MissingClaim("vct".to_string())); + } + + Ok(IetfSdJwtVc { + id: Uuid::new_v4(), + key_alias: None, + inner: value, + claims, + }) + } +} + +impl TryFrom<(Uuid, SdJwtBuf)> for IetfSdJwtVc { + type Error = IetfSdJwtVcError; + + fn try_from(value: (Uuid, SdJwtBuf)) -> Result { + let revealed = value + .1 + .decode_reveal::() + .map_err(|e| IetfSdJwtVcError::SdJwtDecoding(format!("{e:?}")))?; + + let claims = serde_json::to_value(revealed.claims()) + .map_err(|e| IetfSdJwtVcError::Serialization(format!("{e:?}")))?; + + if claims.get("vct").and_then(|v| v.as_str()).is_none() { + return Err(IetfSdJwtVcError::MissingClaim("vct".to_string())); + } + + Ok(IetfSdJwtVc { + id: value.0, + key_alias: None, + inner: value.1, + claims, + }) + } +} + +impl TryFrom<&Credential> for IetfSdJwtVc { + type Error = IetfSdJwtVcError; + + fn try_from(value: &Credential) -> Result { + let inner = SdJwtBuf::new(value.payload.clone()) + .map_err(|_| IetfSdJwtVcError::InvalidSdJwt(Default::default()))?; + + let mut sd_jwt = IetfSdJwtVc::try_from(inner)?; + sd_jwt.id = value.id; + sd_jwt.key_alias = value.key_alias.clone(); + + Ok(sd_jwt) + } +} + +impl TryFrom for Arc { + type Error = IetfSdJwtVcError; + + fn try_from(value: Credential) -> Result, IetfSdJwtVcError> { + Ok(Arc::new(IetfSdJwtVc::try_from(&value)?)) + } +} + +impl TryFrom> for Credential { + type Error = IetfSdJwtVcError; + + fn try_from(value: Arc) -> Result { + Ok(Credential { + id: value.id, + format: CredentialFormat::DcSdJwt, + r#type: value.r#type(), + payload: value.inner.as_bytes().into(), + key_alias: value.key_alias.clone(), + }) + } +} + +#[derive(Debug, uniffi::Error, thiserror::Error)] +pub enum IetfSdJwtVcError { + #[error("failed to initialize IETF SD-JWT VC: {0}")] + InitError(String), + #[error("failed to decode SD-JWT: {0}")] + SdJwtDecoding(String), + #[error("invalid SD-JWT: {0}")] + InvalidSdJwt(String), + #[error("serialization error: {0}")] + Serialization(String), + #[error("failed to encode credential: {0}")] + CredentialEncoding(String), + #[error("missing required claim: {0}")] + MissingClaim(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_identifier() { + assert_eq!(FORMAT_DC_SD_JWT, "dc+sd-jwt"); + } + + #[test] + fn test_parse_dc_sd_jwt_credential() { + let credential = include_str!("../../../tests/examples/dc+sd-jwt.jwt"); + + // Verify raw SD-JWT parsing and claim revelation. + let buf = SdJwtBuf::new(credential.to_string()).expect("SdJwtBuf::new should succeed"); + let revealed = buf + .decode_reveal::() + .expect("decode_reveal should succeed"); + let claims = revealed.claims(); + assert!( + claims.private.get("vct").is_some(), + "revealed claims should contain vct" + ); + + // Verify IetfSdJwtVc construction and accessors. + let vc = IetfSdJwtVc::new_from_compact_sd_jwt(credential.to_string()) + .expect("new_from_compact_sd_jwt should succeed"); + + assert_eq!(vc.vct(), "eu.europa.ec.eudi.hiid.1"); + // This credential conveys issuer via x5c header, so iss claim is absent. + assert_eq!(vc.issuer(), None); + + // Verify revealed claims include selectively disclosed fields. + let json = vc + .revealed_claims_as_json() + .expect("revealed_claims_as_json should succeed"); + assert_eq!( + json.get("health_insurance_id").and_then(|v| v.as_str()), + Some("A123456780101575519DE") + ); + assert_eq!( + json.get("affiliation_country").and_then(|v| v.as_str()), + Some("DE") + ); + assert_eq!( + json.get("issuing_country").and_then(|v| v.as_str()), + Some("DE") + ); + } +} diff --git a/rust/src/credential/format/mod.rs b/rust/src/credential/format/mod.rs index bf563bc6..cf1fafe0 100644 --- a/rust/src/credential/format/mod.rs +++ b/rust/src/credential/format/mod.rs @@ -1,4 +1,5 @@ pub mod cwt; +pub mod ietf_sd_jwt_vc; pub mod json_vc; pub mod jwt_vc; pub mod mdoc; diff --git a/rust/src/credential/mod.rs b/rust/src/credential/mod.rs index 3cde3cdf..854e1fa9 100644 --- a/rust/src/credential/mod.rs +++ b/rust/src/credential/mod.rs @@ -10,6 +10,7 @@ use crate::{ CredentialType, }; use cwt::{Cwt, CwtError}; +use ietf_sd_jwt_vc::{IetfSdJwtVc, IetfSdJwtVcError}; use json_vc::{JsonVc, JsonVcEncodingError, JsonVcInitError}; use jwt_vc::{JwtVc, JwtVcInitError}; use mdoc::{Mdoc, MdocEncodingError, MdocInitError}; @@ -79,11 +80,9 @@ pub(crate) enum ParsedCredentialInner { JwtVcJson(Arc), JwtVcJsonLd(Arc), VCDM2SdJwt(Arc), + DcSdJwt(Arc), LdpVc(Arc), Cwt(Arc), - // More to come, for example: - // SdJwt(...), - // SdJwtJoseCose(...), } #[uniffi::export] @@ -96,13 +95,14 @@ impl PresentableCredential { } /// Return if the credential supports selective disclosure - /// For now only SdJwts are supported + /// SD-JWT formats support selective disclosure pub fn selective_disclosable(&self) -> bool { match &self.inner { ParsedCredentialInner::MsoMdoc(_) => false, ParsedCredentialInner::JwtVcJson(_) => false, ParsedCredentialInner::JwtVcJsonLd(_) => false, ParsedCredentialInner::VCDM2SdJwt(_) => true, + ParsedCredentialInner::DcSdJwt(_) => true, ParsedCredentialInner::LdpVc(_) => false, ParsedCredentialInner::Cwt(_) => false, } @@ -146,6 +146,10 @@ impl ParsedCredential { let sd_jwt = VCDM2SdJwt::new_from_compact_sd_jwt_with_key(credential, key_alias)?; Ok(ParsedCredential::new_sd_jwt(sd_jwt)) } + CredentialFormat::DcSdJwt => { + let sd_jwt = IetfSdJwtVc::new_from_compact_sd_jwt_with_key(credential, key_alias)?; + Ok(ParsedCredential::new_dc_sd_jwt(sd_jwt)) + } CredentialFormat::Cwt => { let cwt = Cwt::new_from_base10(credential)?; Ok(ParsedCredential::new_cwt(cwt)) @@ -190,6 +194,11 @@ impl ParsedCredential { VCDM2SdJwt::from_compact_sd_jwt_with_id_and_key(id, credential, key_alias)?; Ok(ParsedCredential::new_sd_jwt(sd_jwt)) } + CredentialFormat::DcSdJwt => { + let sd_jwt = + IetfSdJwtVc::from_compact_sd_jwt_with_id_and_key(id, credential, key_alias)?; + Ok(ParsedCredential::new_dc_sd_jwt(sd_jwt)) + } CredentialFormat::Cwt => { let cwt = Cwt::from_base10(id, credential.as_bytes().into())?.into(); Ok(ParsedCredential::new_cwt(cwt)) @@ -240,13 +249,21 @@ impl ParsedCredential { } #[uniffi::constructor] - /// Construct a new `sd_jwt_vc` credential. + /// Construct a new `sd_jwt_vc` credential (VCDM2 format). pub fn new_sd_jwt(sd_jwt_vc: Arc) -> Arc { Arc::new(Self { inner: ParsedCredentialInner::VCDM2SdJwt(sd_jwt_vc), }) } + #[uniffi::constructor] + /// Construct a new `dc+sd-jwt` credential (IETF SD-JWT VC format). + pub fn new_dc_sd_jwt(dc_sd_jwt: Arc) -> Arc { + Arc::new(Self { + inner: ParsedCredentialInner::DcSdJwt(dc_sd_jwt), + }) + } + #[uniffi::constructor] /// Construct a new `cwt` credential. pub fn new_cwt(cwt: Arc) -> Arc { @@ -286,6 +303,13 @@ impl ParsedCredential { payload: sd_jwt.inner.as_bytes().into(), key_alias: sd_jwt.key_alias(), }), + ParsedCredentialInner::DcSdJwt(sd_jwt) => Ok(Credential { + id: sd_jwt.id(), + format: CredentialFormat::DcSdJwt, + r#type: sd_jwt.r#type(), + payload: sd_jwt.inner.as_bytes().into(), + key_alias: sd_jwt.key_alias(), + }), ParsedCredentialInner::JwtVcJsonLd(vc) => Ok(Credential { id: vc.id(), format: CredentialFormat::JwtVcJsonLd, @@ -317,6 +341,7 @@ impl ParsedCredential { ParsedCredentialInner::JwtVcJson(_) => CredentialFormat::JwtVcJson, ParsedCredentialInner::JwtVcJsonLd(_) => CredentialFormat::JwtVcJsonLd, ParsedCredentialInner::VCDM2SdJwt(_) => CredentialFormat::VCDM2SdJwt, + ParsedCredentialInner::DcSdJwt(_) => CredentialFormat::DcSdJwt, ParsedCredentialInner::Cwt(_) => CredentialFormat::Cwt, ParsedCredentialInner::LdpVc(_) => CredentialFormat::LdpVc, } @@ -330,6 +355,7 @@ impl ParsedCredential { ParsedCredentialInner::JwtVcJsonLd(arc) => arc.id(), ParsedCredentialInner::LdpVc(arc) => arc.id(), ParsedCredentialInner::VCDM2SdJwt(arc) => arc.id(), + ParsedCredentialInner::DcSdJwt(arc) => arc.id(), ParsedCredentialInner::Cwt(arc) => arc.id(), } } @@ -342,6 +368,7 @@ impl ParsedCredential { ParsedCredentialInner::JwtVcJsonLd(arc) => arc.key_alias(), ParsedCredentialInner::LdpVc(arc) => arc.key_alias(), ParsedCredentialInner::VCDM2SdJwt(arc) => arc.key_alias(), + ParsedCredentialInner::DcSdJwt(arc) => arc.key_alias(), ParsedCredentialInner::Cwt(arc) => arc.key_alias(), } } @@ -354,6 +381,7 @@ impl ParsedCredential { ParsedCredentialInner::JwtVcJsonLd(arc) => arc.r#type(), ParsedCredentialInner::LdpVc(arc) => arc.r#type(), ParsedCredentialInner::VCDM2SdJwt(arc) => arc.r#type(), + ParsedCredentialInner::DcSdJwt(arc) => arc.r#type(), ParsedCredentialInner::Cwt(arc) => arc.r#type(), } } @@ -383,7 +411,7 @@ impl ParsedCredential { } } - /// Return the credential as an SD-JWT, if it is of that format. + /// Return the credential as an SD-JWT (VCDM2 format), if it is of that format. pub fn as_sd_jwt(&self) -> Option> { match &self.inner { ParsedCredentialInner::VCDM2SdJwt(sd_jwt) => Some(sd_jwt.clone()), @@ -391,6 +419,14 @@ impl ParsedCredential { } } + /// Return the credential as an IETF SD-JWT VC (dc+sd-jwt format), if it is of that format. + pub fn as_dc_sd_jwt(&self) -> Option> { + match &self.inner { + ParsedCredentialInner::DcSdJwt(sd_jwt) => Some(sd_jwt.clone()), + _ => None, + } + } + /// Return the credential as an CWT, if it is of that format. pub fn as_cwt(&self) -> Option> { match &self.inner { @@ -413,6 +449,11 @@ impl PresentableCredential { .as_vp_token_item(options, self.selected_fields.clone()) .await } + ParsedCredentialInner::DcSdJwt(sd_jwt) => { + sd_jwt + .as_vp_token_item(options, self.selected_fields.clone()) + .await + } ParsedCredentialInner::JwtVcJson(vc) | ParsedCredentialInner::JwtVcJsonLd(vc) => { vc.as_vp_token_item(options, None).await } @@ -421,10 +462,9 @@ impl PresentableCredential { mdoc.as_vp_token_item(options, self.selected_fields.clone()) .await } - _ => Err(CredentialEncodingError::VpToken(format!( - "Credential encoding for VP Token is not implemented for {:?}.", - self.inner, - )) + ParsedCredentialInner::Cwt(_) => Err(CredentialEncodingError::VpToken( + "Credential encoding for VP Token is not implemented for CWT.".to_string(), + ) .into()), } } @@ -441,6 +481,7 @@ impl ParsedCredential { ParsedCredentialInner::VCDM2SdJwt(sd_jwt) => { sd_jwt.satisfies_dcql_query(credential_query) } + ParsedCredentialInner::DcSdJwt(sd_jwt) => sd_jwt.satisfies_dcql_query(credential_query), ParsedCredentialInner::MsoMdoc(mdoc) => mdoc.satisfies_dcql_query(credential_query), ParsedCredentialInner::Cwt(_cwt) => false, } @@ -466,6 +507,9 @@ impl ParsedCredential { ParsedCredentialInner::VCDM2SdJwt(sd_jwt) => { sd_jwt.requested_fields_dcql(credential_query) } + ParsedCredentialInner::DcSdJwt(sd_jwt) => { + sd_jwt.requested_fields_dcql(credential_query) + } ParsedCredentialInner::JwtVcJson(vc) => vc.requested_fields_dcql(credential_query), ParsedCredentialInner::JwtVcJsonLd(vc) => vc.requested_fields_dcql(credential_query), ParsedCredentialInner::LdpVc(vc) => vc.requested_fields_dcql(credential_query), @@ -519,6 +563,9 @@ impl TryFrom for Arc { CredentialFormat::VCDM2SdJwt => { Ok(ParsedCredential::new_sd_jwt(credential.try_into()?)) } + CredentialFormat::DcSdJwt => { + Ok(ParsedCredential::new_dc_sd_jwt(credential.try_into()?)) + } CredentialFormat::LdpVc => Ok(ParsedCredential::new_ldp_vc(credential.try_into()?)), _ => Err(CredentialDecodingError::UnsupportedCredentialFormat( credential.format.to_string(), @@ -557,6 +604,8 @@ pub enum CredentialDecodingError { JwtVc(#[from] JwtVcInitError), #[error("SD JWT VC decoding error: {0}")] SdJwt(#[from] SdJwtError), + #[error("IETF SD-JWT VC decoding error: {0}")] + IetfSdJwtVc(#[from] IetfSdJwtVcError), #[error("Cwt decoding error: {0}")] Cwt(#[from] CwtError), #[error("Credential format is not yet supported for type: {0}")] @@ -586,6 +635,8 @@ pub enum CredentialFormat { LdpVc, #[serde(rename = "vcdm2_sd_jwt")] VCDM2SdJwt, + #[serde(rename = "dc+sd-jwt")] + DcSdJwt, Cwt, #[serde(untagged)] Other(String), // For ease of expansion. @@ -599,6 +650,7 @@ impl std::fmt::Display for CredentialFormat { CredentialFormat::JwtVcJsonLd => write!(f, "jwt_vc_json-ld"), CredentialFormat::LdpVc => write!(f, "ldp_vc"), CredentialFormat::VCDM2SdJwt => write!(f, "vcdm2_sd_jwt"), + CredentialFormat::DcSdJwt => write!(f, "dc+sd-jwt"), CredentialFormat::Cwt => write!(f, "cwt"), CredentialFormat::Other(s) => write!(f, "{s}"), } @@ -619,6 +671,7 @@ impl From for CredentialFormat { "jwt_vc_json-ld" => CredentialFormat::JwtVcJsonLd, "ldp_vc" => CredentialFormat::LdpVc, "vcdm2_sd_jwt" => CredentialFormat::VCDM2SdJwt, + "dc+sd-jwt" => CredentialFormat::DcSdJwt, _ => CredentialFormat::Other(value), } } diff --git a/rust/src/oid4vci/credential.rs b/rust/src/oid4vci/credential.rs index 16ea1504..ebb00586 100644 --- a/rust/src/oid4vci/credential.rs +++ b/rust/src/oid4vci/credential.rs @@ -1,4 +1,4 @@ -use oid4vci::profile::{StandardFormat, W3cVcFormat, FORMAT_DC_SD_JWT}; +use oid4vci::profile::{StandardFormat, W3cVcFormat}; use crate::{ credential::{vcdm2_sd_jwt::SPRUCE_FORMAT_VC_SD_JWT, CredentialFormat, RawCredential}, @@ -11,16 +11,13 @@ impl RawCredential { credential: oid4vci::Oid4vciCredential, ) -> Result { match format { - StandardFormat::DcSdJwt => { - // TODO add proper support for DC+SD-JWT. - match credential.value { - serde_json::Value::String(dc_sd_jwt) => Ok(Self { - format: CredentialFormat::Other(FORMAT_DC_SD_JWT.to_owned()), - payload: dc_sd_jwt.into_bytes(), - }), - _ => Err(Oid4vciError::InvalidCredentialPayload), - } - } + StandardFormat::DcSdJwt => match credential.value { + serde_json::Value::String(dc_sd_jwt) => Ok(Self { + format: CredentialFormat::DcSdJwt, + payload: dc_sd_jwt.into_bytes(), + }), + _ => Err(Oid4vciError::InvalidCredentialPayload), + }, StandardFormat::W3c(format) => Ok(Self { format: match format { W3cVcFormat::LdpVc => CredentialFormat::LdpVc, diff --git a/rust/src/oid4vp/holder.rs b/rust/src/oid4vp/holder.rs index af69f610..a646a9a2 100644 --- a/rust/src/oid4vp/holder.rs +++ b/rust/src/oid4vp/holder.rs @@ -231,6 +231,16 @@ impl Holder { ClaimFormatPayload::AlgValues(vec!["ES256".into()]), ); + // Insert support for IETF SD-JWT VC (dc+sd-jwt) format. + // Per draft-ietf-oauth-sd-jwt-vc-14. + metadata.vp_formats_supported_mut().0.insert( + ClaimFormatDesignation::DcSdJwt, + ClaimFormatPayload::Other(serde_json::json!({ + "sd-jwt_alg_values": ["ES256"], + "kb-jwt_alg_values": ["ES256"] + })), + ); + metadata // Insert support for client ID prefixes. .add_client_id_prefixes_supported(&[ diff --git a/rust/src/presentation/mod.rs b/rust/src/presentation/mod.rs index 70878188..6f196d4c 100644 --- a/rust/src/presentation/mod.rs +++ b/rust/src/presentation/mod.rs @@ -89,6 +89,9 @@ impl JsonLdPresentationBuilder { ParsedCredentialInner::VCDM2SdJwt(_) => { Err(PresentationBuilderError::UnsupportedCredentialFormat) } + ParsedCredentialInner::DcSdJwt(_) => { + Err(PresentationBuilderError::UnsupportedCredentialFormat) + } ParsedCredentialInner::LdpVc(ldp_vc) => Ok(ldp_vc.raw.clone()), ParsedCredentialInner::Cwt(_) => { Err(PresentationBuilderError::UnsupportedCredentialFormat) diff --git a/rust/tests/examples/dc+sd-jwt.jwt b/rust/tests/examples/dc+sd-jwt.jwt new file mode 100644 index 00000000..50e1d901 --- /dev/null +++ b/rust/tests/examples/dc+sd-jwt.jwt @@ -0,0 +1 @@ +eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlCN3pDQ0FaV2dBd0lCQWdJUUozMUFXTUpTV0xxUHppS21VZGhCUVRBS0JnZ3Foa2pPUFFRREFqQWRNUTR3REFZRFZRUURFd1ZCYm1sdGJ6RUxNQWtHQTFVRUJoTUNUa3d3SGhjTk1qVXhNVEE0TVRZeU9ETXdXaGNOTWpZeE1USTRNVFl5T0RNd1dqQWhNUkl3RUFZRFZRUURFd2xqY21Wa2J5QmtZM014Q3pBSkJnTlZCQVlUQWs1TU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRUoxT0VsVHZ4SkhKQmh5bHQvNkx0NDRlVVlzTHlBQ2xGZWgvKzhOeFhNZ2xCeWRNYi96bk9LMCtHM3hHMzExSFpiVTdMTm9BL2FQblNLeVY0MnlWL2dLT0JzakNCcnpBZEJnTlZIUTRFRmdRVXhqL2dCUzIzY3RtN25vZ3hOcEIrWVpJNlV6c3dEZ1lEVlIwUEFRSC9CQVFEQWdlQU1CVUdBMVVkSlFFQi93UUxNQWtHQnlpQmpGMEZBUUl3SHdZRFZSMGpCQmd3Rm9BVVZDNVhXMVBUWU5vNnlXbmtKR2d2QlZDdFdaVXdKZ1lEVlIwU0JCOHdIWVliYUhSMGNITTZMeTl3YkdGNVozSnZkVzVrTG1GdWFXMXZMbWxrTUI0R0ExVWRFUVFYTUJXQ0UzQnNZWGxuY205MWJtUXVZVzVwYlc4dWFXUXdDZ1lJS29aSXpqMEVBd0lEU0FBd1JRSWdMNUVHVWh6aHdMKzdJbFlKVGd5M2grcnVobmh5R3YyNUpYSnR1d0w2VGxVQ0lRQ0Zhc1d0OElzd3hwUXhCbHI3UjdNdWxkZmFmREh0aXA3RzVBcExNMTFMZFE9PSJdfQ.eyJpc3N1YW5jZV9kYXRlIjoiMjAyNi0wMi0wMiIsImV4cGlyeV9kYXRlIjoiMjAyNy0wMi0xMiIsImlzc3VpbmdfYXV0aG9yaXR5IjoiREUiLCJpc3N1aW5nX2NvdW50cnkiOiJERSIsIm5iZiI6MTc2OTk5MDQwMCwiZXhwIjoxODAyMzkwNDAwLCJ2Y3QiOiJldS5ldXJvcGEuZWMuZXVkaS5oaWlkLjEiLCJjbmYiOnsia2lkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmlJc0ltTnlkaUk2SWxBdE1qVTJJaXdpYTNSNUlqb2lSVU1pTENKNElqb2lWUzFsYUhkUFZ6WllPUzExZDBoQ1EyWk5Oa2RVTkRneFgyaG5ZVVJHZWpSSFVIbEVVbnAzUWpOWFp5SXNJbmtpT2lKcFFqTnlaVlYxZDIxNFUxaGxXV1paVHpSV1JERnBUVkJXVUdSU1FtMVdPRXh6TUdSbWNFeENjVEZWSW4wIzAifSwiaWF0IjoxNzcxNDI1ODYyLCJfc2QiOlsiWXVySnA0dGNoZ21GQTJETU9WMlhHdU9EZUlyczRxTlJIektENUR1dzZNQSIsIm91el9JemprNlMxTEZtcGwzYjhreWIzYWVHTEhpOVA1MVctOG96NG5FbEUiLCJ6UFdQQkZvTWhfVE1FSE5aMjJKM3ZBUmlJMEJpZGpRNDI2NWtZRFB0d1VVIl0sIl9zZF9hbGciOiJzaGEtMjU2In0.zs1QRrRhVk3nEo6XSd4h-9QVjHZ4V7MaaEkURo7isGl2lJ1ShZM03AVkoU78JmgnhXnvD9ilkke7tTzkunXzfg~WyJ3dXBVSThvdnhtc3Z4MFpxIiwiaGVhbHRoX2luc3VyYW5jZV9pZCIsIkExMjM0NTY3ODAxMDE1NzU1MTlERSJd~WyJVc1ZueWllRURtaTltanFXIiwiYWZmaWxpYXRpb25fY291bnRyeSIsIkRFIl0~WyJDTzhmbFZwM3phTVBkZUxUIiwid2FsbGV0X2VfcHJlc2NyaXB0aW9uX2NvZGUiLCIxNjAuMDAwLjAzMy40OTEuMzUyLjU2Jjk0Yzc1ZTE1ZTRjNGRkNmI1MGUzYzE4YjkyYjQ3NTRlODhmZWM0YWIxNDRlODZhMWI5NWRmMTIwOTc2Nzk3OGImbWVkaWNhdGlvbiBuYW1lIl0~ \ No newline at end of file