diff --git a/CHANGELOG.md b/CHANGELOG.md index 748121bdd..844e490b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ Release 5.1.0: - `WalletService` supports building multiple authorization details to request a token for more than one credential - Remove `buildAuthorizationDetails(RequestOptions)` for `WalletService`, please migrate to `buildScope(RequestOptions)` - Note that multiple `scope` values may be joined with a whitespace ` ` + - SD-JWT: + - Pass around decoded data with `SdJwtSigned` in several result classes like `VerifyPresentationResult.SuccessSdJwt` + - Rename `disclosures` to `reconstructed` in several result classes like `AuthnResponseResult.SuccessSdJwt` + - Correctly implement confirmation claim in `VerifiableCredentialSdJwt`, migrating from `JsonWebKey` to `ConfirmationClaim` Release 5.0.1: - Update JsonPath4K to 2.4.0 diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt index df3971b45..19b7de62a 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt @@ -21,10 +21,7 @@ import at.asitplus.wallet.lib.agent.* import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.data.ConstantIndex.supportsSdJwt import at.asitplus.wallet.lib.data.ConstantIndex.supportsVcJwt -import at.asitplus.wallet.lib.jws.DefaultJwsService -import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService -import at.asitplus.wallet.lib.jws.JwsService -import at.asitplus.wallet.lib.jws.VerifierJwsService +import at.asitplus.wallet.lib.jws.* import at.asitplus.wallet.lib.oidvci.* import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier @@ -503,9 +500,12 @@ class OidcSiopVerifier private constructor( * Successfully decoded and validated the response from the Wallet (W3C credential in SD-JWT) */ data class SuccessSdJwt( - val jwsSigned: JwsSigned, + val sdJwtSigned: SdJwtSigned, + val verifiableCredentialSdJwt: VerifiableCredentialSdJwt, + @Deprecated("Renamed to verifiableCredentialSdJwt", replaceWith = ReplaceWith("verifiableCredentialSdJwt")) val sdJwt: VerifiableCredentialSdJwt, - val disclosures: List, + val reconstructed: ReconstructedSdJwtClaims, + val disclosures: Collection, val state: String?, ) : AuthnResponseResult() @@ -672,6 +672,7 @@ class OidcSiopVerifier private constructor( else -> throw IllegalArgumentException() } + @Suppress("DEPRECATION") private fun Verifier.VerifyPresentationResult.mapToAuthnResponseResult(state: String) = when (this) { is Verifier.VerifyPresentationResult.InvalidStructure -> AuthnResponseResult.Error("parse vp failed", state) @@ -690,8 +691,14 @@ class OidcSiopVerifier private constructor( .also { Napier.i("VP success: $this") } is Verifier.VerifyPresentationResult.SuccessSdJwt -> - AuthnResponseResult.SuccessSdJwt(jwsSigned, sdJwt, disclosures, state) - .also { Napier.i("VP success: $this") } + AuthnResponseResult.SuccessSdJwt( + sdJwtSigned, + verifiableCredentialSdJwt, + sdJwt, + reconstructed, + disclosures, + state + ).also { Napier.i("VP success: $this") } } } diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt index 8fb559db6..478a5b2f0 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopCombinedProtocolTest.kt @@ -137,7 +137,7 @@ class OidcSiopCombinedProtocolTest : FreeSpec({ val result = verifierSiop.validateAuthnResponse(authnResponse.url) .shouldBeInstanceOf() - result.sdJwt.type?.shouldContain(ConstantIndex.AtomicAttribute2023.vcType) + result.verifiableCredentialSdJwt.type?.shouldContain(ConstantIndex.AtomicAttribute2023.vcType) } } @@ -242,21 +242,21 @@ class OidcSiopCombinedProtocolTest : FreeSpec({ groupedResult.validationResults.size shouldBe 2 groupedResult.validationResults.forEach { result -> result.shouldBeInstanceOf() - result.disclosures.shouldNotBeEmpty() - when (result.sdJwt.verifiableCredentialType) { + result.reconstructed.claims.shouldNotBeEmpty() + when (result.verifiableCredentialSdJwt.verifiableCredentialType) { EuPidScheme.sdJwtType -> { - result.disclosures.firstOrNull { it.claimName == EuPidScheme.Attributes.FAMILY_NAME } + result.reconstructed.claims.firstOrNull { it.claimName == EuPidScheme.Attributes.FAMILY_NAME } .shouldNotBeNull() - result.disclosures.firstOrNull { it.claimName == EuPidScheme.Attributes.GIVEN_NAME } + result.reconstructed.claims.firstOrNull { it.claimName == EuPidScheme.Attributes.GIVEN_NAME } .shouldNotBeNull() } ConstantIndex.AtomicAttribute2023.sdJwtType -> { - result.disclosures.firstOrNull() { it.claimName == CLAIM_DATE_OF_BIRTH }.shouldNotBeNull() + result.reconstructed.claims.firstOrNull { it.claimName == CLAIM_DATE_OF_BIRTH }.shouldNotBeNull() } else -> { - fail("Unexpected SD-JWT type: ${result.sdJwt.verifiableCredentialType}") + fail("Unexpected SD-JWT type: ${result.verifiableCredentialSdJwt.verifiableCredentialType}") } } } diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt index 849416409..5e8b08f9a 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt @@ -7,6 +7,7 @@ import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN import at.asitplus.wallet.lib.data.IsoDocumentParsed import at.asitplus.wallet.lib.oidvci.formUrlEncode import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements +import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.FAMILY_NAME import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec @@ -112,7 +113,7 @@ class OidcSiopIsoProtocolTest : FreeSpec({ } "Selective Disclosure with mDL" { - val requestedClaim = MobileDrivingLicenceDataElements.FAMILY_NAME + val requestedClaim = FAMILY_NAME verifierSiop = OidcSiopVerifier( keyMaterial = verifierKeyMaterial, clientIdScheme = OidcSiopVerifier.ClientIdScheme.RedirectUri(clientId), @@ -132,14 +133,13 @@ class OidcSiopIsoProtocolTest : FreeSpec({ holderSiop, ) - document.validItems.shouldNotBeEmpty() document.validItems.shouldBeSingleton() document.validItems.shouldHaveSingleElement { it.elementIdentifier == requestedClaim } document.invalidItems.shouldBeEmpty() } "Selective Disclosure with mDL and encryption" { - val requestedClaim = MobileDrivingLicenceDataElements.FAMILY_NAME + val requestedClaim = FAMILY_NAME verifierSiop = OidcSiopVerifier( keyMaterial = verifierKeyMaterial, clientIdScheme = OidcSiopVerifier.ClientIdScheme.RedirectUri(clientId), @@ -167,7 +167,6 @@ class OidcSiopIsoProtocolTest : FreeSpec({ val document = result.documents.first() - document.validItems.shouldNotBeEmpty() document.validItems.shouldBeSingleton() document.validItems.shouldHaveSingleElement { it.elementIdentifier == requestedClaim } document.invalidItems.shouldBeEmpty() @@ -186,16 +185,15 @@ class OidcSiopIsoProtocolTest : FreeSpec({ OidcSiopVerifier.RequestOptionsCredential( MobileDrivingLicenceScheme, ConstantIndex.CredentialRepresentation.ISO_MDOC, - listOf(MobileDrivingLicenceDataElements.FAMILY_NAME) + listOf(FAMILY_NAME) ) ) ), holderSiop, ) - document.validItems.shouldNotBeEmpty() document.validItems.shouldBeSingleton() - document.validItems.shouldHaveSingleElement { it.elementIdentifier == MobileDrivingLicenceDataElements.FAMILY_NAME } + document.validItems.shouldHaveSingleElement { it.elementIdentifier == FAMILY_NAME } document.invalidItems.shouldBeEmpty() } diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt index 35222e8ea..105fc9ed5 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopSdJwtProtocolTest.kt @@ -6,9 +6,7 @@ import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.RequestOptions import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldHaveSingleElement -import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf @@ -79,10 +77,8 @@ class OidcSiopSdJwtProtocolTest : FreeSpec({ val result = verifierSiop.validateAuthnResponse(authnResponse.url) result.shouldBeInstanceOf() - result.sdJwt.shouldNotBeNull() - result.disclosures.shouldNotBeEmpty() - result.disclosures.shouldBeSingleton() - result.disclosures.shouldHaveSingleElement { it.claimName == requestedClaim } + result.verifiableCredentialSdJwt.shouldNotBeNull() + result.reconstructed.claims.shouldHaveSingleElement { it.claimName == requestedClaim } } }) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt index c67b411f2..f6483c4dc 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopX509SanDnsTest.kt @@ -14,7 +14,7 @@ import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN import at.asitplus.wallet.lib.oidc.OidcSiopVerifier.RequestOptions import at.asitplus.wallet.lib.oidvci.formUrlEncode import io.kotest.core.spec.style.FreeSpec -import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.collections.shouldHaveSingleElement import io.kotest.matchers.types.shouldBeInstanceOf class OidcSiopX509SanDnsTest : FreeSpec({ @@ -86,7 +86,7 @@ class OidcSiopX509SanDnsTest : FreeSpec({ val result = verifierSiop.validateAuthnResponseFromPost(authnResponse.params.formUrlEncode()) result.shouldBeInstanceOf() - result.disclosures.shouldNotBeEmpty() + result.reconstructed.claims.shouldHaveSingleElement { it.claimName == CLAIM_GIVEN_NAME } } }) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt index 924a781e8..278843423 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt @@ -83,7 +83,7 @@ class HolderAgent( throw VerificationError(sdJwt.toString()) } subjectCredentialStore.storeCredential( - sdJwt.sdJwt, + sdJwt.verifiableCredentialSdJwt, credential.vcSdJwt, sdJwt.disclosures, credential.scheme, diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt index 21b87e0f9..2dda2f3dd 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/IssuerAgent.kt @@ -6,6 +6,7 @@ import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.io.Base64Strict import at.asitplus.signum.indispensable.io.BitSet +import at.asitplus.signum.indispensable.josef.ConfirmationClaim import at.asitplus.signum.indispensable.josef.toJsonWebKey import at.asitplus.wallet.lib.DataSourceProblem import at.asitplus.wallet.lib.DefaultZlibService @@ -19,11 +20,13 @@ import at.asitplus.wallet.lib.iso.* import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.JwsService +import at.asitplus.wallet.lib.jws.SdJwtSigned import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.serialization.json.encodeToJsonElement import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -163,11 +166,8 @@ class IssuerAgent( ) ?: throw IllegalArgumentException("No statusListIndex from issuerCredentialStore") val credentialStatus = CredentialStatus(getRevocationListUrlFor(timePeriod), statusListIndex) - val disclosures = credential.claims - .map { SelectiveDisclosureItem(Random.nextBytes(32), it.name, it.value) } - .map { it.toDisclosure() } - val disclosureDigests = disclosures - .map { it.hashDisclosure() } + val (disclosures, disclosureDigests) = credential.toDisclosuresAndDigests() + val cnf = ConfirmationClaim(jsonWebKey = credential.subjectPublicKey.toJsonWebKey()) val jwsPayload = VerifiableCredentialSdJwt( subject = subjectId, notBefore = issuanceDate, @@ -178,7 +178,7 @@ class IssuerAgent( disclosureDigests = disclosureDigests, verifiableCredentialType = credential.scheme.sdJwtType ?: credential.scheme.schemaUri, selectiveDisclosureAlgorithm = "sha-256", - confirmationKey = credential.subjectPublicKey.toJsonWebKey(), + cnfElement = vckJsonSerializer.encodeToJsonElement(cnf), credentialStatus = credentialStatus, ).serialize().encodeToByteArray() val jws = jwsService.createSignedJwt(JwsContentTypeConstants.SD_JWT, jwsPayload).getOrElse { @@ -189,6 +189,21 @@ class IssuerAgent( return Issuer.IssuedCredential.VcSdJwt(vcInSdJwt, credential.scheme) } + data class DisclosuresAndDigests( + val disclosures: Collection, + val digests: Collection, + ) + + private fun CredentialToBeIssued.VcSd.toDisclosuresAndDigests(): DisclosuresAndDigests { + val disclosures = claims + .map { SelectiveDisclosureItem(Random.nextBytes(32), it.name, it.value) } + .map { it.toDisclosure() } + // may also include decoy digests + val disclosureDigests = disclosures + .map { it.hashDisclosure() } + return DisclosuresAndDigests(disclosures, disclosureDigests) + } + /** * Wraps the revocation information from [issuerCredentialStore] into a VC, * returns a JWS representation of that. diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt index df87b69b7..71772d7ed 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/SubjectCredentialStore.kt @@ -1,11 +1,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult -import at.asitplus.wallet.lib.data.ConstantIndex -import at.asitplus.wallet.lib.data.SelectiveDisclosureItem -import at.asitplus.wallet.lib.data.VerifiableCredential -import at.asitplus.wallet.lib.data.VerifiableCredentialJws -import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt +import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.iso.IssuerSigned import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -33,7 +29,7 @@ interface SubjectCredentialStore { * Passed credentials have been validated before. * * @param vc Instance of [VerifiableCredentialSdJwt] - * @param vcSerialized Serialized form of [VerifiableCredential] + * @param vcSerialized Serialized form of [at.asitplus.wallet.lib.jws.SdJwtSigned] */ suspend fun storeCredential( vc: VerifiableCredentialSdJwt, @@ -80,9 +76,7 @@ interface SubjectCredentialStore { val vcSerialized: String, @SerialName("sd-jwt") val sdJwt: VerifiableCredentialSdJwt, - /** - * Map of original serialized disclosure item to parsed item - */ + /** Map of serialized disclosure item (as [String]) to parsed item (as [SelectiveDisclosureItem]) */ @SerialName("disclosures") val disclosures: Map, @SerialName("scheme") diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt index d43a40d80..2375df7fb 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Validator.kt @@ -6,6 +6,7 @@ import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper import at.asitplus.signum.indispensable.cosef.toCoseKey import at.asitplus.signum.indispensable.equalsCryptographically import at.asitplus.signum.indispensable.io.Base64Strict +import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.io.BitSet import at.asitplus.signum.indispensable.io.toBitSet import at.asitplus.signum.indispensable.josef.JwsSigned @@ -18,10 +19,12 @@ import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.data.SelectiveDisclosureItem.Companion.hashDisclosure import at.asitplus.wallet.lib.iso.* import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService +import at.asitplus.wallet.lib.jws.ReconstructedSdJwtClaims import at.asitplus.wallet.lib.jws.SdJwtSigned import at.asitplus.wallet.lib.jws.VerifierJwsService import io.github.aakira.napier.Napier import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArrayOrNull import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString @@ -188,6 +191,13 @@ class Validator( return Verifier.VerifyPresentationResult.Success(vp) } + /** + * Validates the content of a SD-JWT presentation, expected to contain a [VerifiableCredentialSdJwt], + * as well as some disclosures and a key binding JWT at the end. + * + * @param input SD-JWT in compact representation, i.e. `$jws~$disclosure1~$disclosure2...~$keyBinding` + * @param publicKey Local key of the verifier, to verify audience of key binding JWS + */ fun verifyVpSdJwt( input: String, challenge: String, @@ -199,7 +209,7 @@ class Validator( return Verifier.VerifyPresentationResult.InvalidStructure(input) .also { Napier.w("verifyVpSdJwt: Could not verify SD-JWT: $sdJwtResult") } } - val jwsKeyBindingParsed = sdJwtResult.keyBindingJws + val jwsKeyBindingParsed = sdJwtResult.sdJwtSigned.keyBindingJws ?: return Verifier.VerifyPresentationResult.NotVerified(input, challenge) .also { Napier.w("verifyVpSdJwt: No key binding JWT") } val keyBinding = KeyBindingJws.deserialize(jwsKeyBindingParsed.payload.decodeToString()).getOrElse { ex -> @@ -213,18 +223,27 @@ class Validator( if (!publicKey.matchesIdentifier(keyBinding.audience)) return Verifier.VerifyPresentationResult.InvalidStructure(input) .also { Napier.w("verifyVpSdJwt: Audience not correct: ${keyBinding.audience}") } - if (sdJwtResult.sdJwt.confirmationKey != null) { + @Suppress("DEPRECATION") + if (sdJwtResult.verifiableCredentialSdJwt.confirmationClaim != null) { + // TODO More general way to verify confirmation claim needed, as it may be a kid, jku, ... jwsKeyBindingParsed.header.jsonWebKey?.let { - if (!sdJwtResult.sdJwt.confirmationKey.equalsCryptographically(it)) { + if (sdJwtResult.verifiableCredentialSdJwt.confirmationClaim?.jsonWebKey?.equalsCryptographically(it) != true) { + Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not prove possession of subject") return Verifier.VerifyPresentationResult.InvalidStructure(input) - .also { Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not prove possession of subject") } } } ?: return Verifier.VerifyPresentationResult.InvalidStructure(input) .also { Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not exist") } - - } else if (jwsKeyBindingParsed.header.keyId != sdJwtResult.sdJwt.subject) { + } else if (sdJwtResult.verifiableCredentialSdJwt.confirmationKey != null) { + jwsKeyBindingParsed.header.jsonWebKey?.let { + if (sdJwtResult.verifiableCredentialSdJwt.confirmationKey?.equalsCryptographically(it) != true) { + Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not prove possession of subject") + return Verifier.VerifyPresentationResult.InvalidStructure(input) + } + } ?: return Verifier.VerifyPresentationResult.InvalidStructure(input) + .also { Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not exist") } + } else if (jwsKeyBindingParsed.header.keyId != sdJwtResult.verifiableCredentialSdJwt.subject) { + Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not prove possession of subject") return Verifier.VerifyPresentationResult.InvalidStructure(input) - .also { Napier.w("verifyVpSdJwt: Key Binding $jwsKeyBindingParsed does not prove possession of subject") } } val hashInput = input.substringBeforeLast("~") + "~" if (!keyBinding.sdHash.contentEquals(hashInput.encodeToByteArray().sha256())) @@ -232,10 +251,13 @@ class Validator( .also { Napier.w("verifyVpSdJwt: Key Binding does not contain correct sd_hash") } Napier.d("verifyVpSdJwt: Valid") + @Suppress("DEPRECATION") return Verifier.VerifyPresentationResult.SuccessSdJwt( - jwsSigned = sdJwtResult.jwsSigned, + sdJwtSigned = sdJwtResult.sdJwtSigned, + verifiableCredentialSdJwt = sdJwtResult.verifiableCredentialSdJwt, sdJwt = sdJwtResult.sdJwt, - disclosures = sdJwtResult.disclosures.values.filterNotNull(), + reconstructed = sdJwtResult.reconstructed, + disclosures = sdJwtResult.disclosures.values, isRevoked = sdJwtResult.isRevoked, ) } @@ -384,15 +406,14 @@ class Validator( * @param publicKey Optionally the local key, to verify SD-JWT was issued to correct subject */ fun verifySdJwt(input: String, publicKey: CryptoPublicKey?): Verifier.VerifyCredentialResult { - Napier.d("Verifying SD-JWT $input") + Napier.d("Verifying SD-JWT $input for $publicKey") val sdJwtSigned = SdJwtSigned.parse(input) ?: return Verifier.VerifyCredentialResult.InvalidStructure(input) .also { Napier.w("verifySdJwt: Could not parse SD-JWT from $input") } if (!verifierJwsService.verifyJwsObject(sdJwtSigned.jws)) return Verifier.VerifyCredentialResult.InvalidStructure(input) .also { Napier.w("verifySdJwt: Signature invalid") } - val payload = sdJwtSigned.jws.payload.decodeToString() - val sdJwt = VerifiableCredentialSdJwt.deserialize(payload).getOrElse { ex -> + val sdJwt = sdJwtSigned.getPayloadAsVerifiableCredentialSdJwt().getOrElse { ex -> return Verifier.VerifyCredentialResult.InvalidStructure(input) .also { Napier.w("verifySdJwt: Could not parse payload", ex) } } @@ -404,24 +425,37 @@ class Validator( val isRevoked = checkRevocationStatus(sdJwt) == RevocationStatus.REVOKED if (isRevoked) Napier.d("verifySdJwt: revoked") - // it's important to read again from source string to prevent different formats in serialization - val disclosureInputs = sdJwtSigned.rawDisclosures.map { it.hashDisclosure() } - disclosureInputs.forEach { discInput -> - if (sdJwt.disclosureDigests?.contains(discInput) != true) { + + /** Map of serialized disclosure item (as [String]) to parsed item (as [SelectiveDisclosureItem]) */ + val validDisclosures: Map = sdJwtSigned.rawDisclosures.filter { + // it's important to read again from source string to prevent different formats in serialization + val hashed = it.hashDisclosure() + if (sdJwt.disclosureDigests?.contains(hashed) != true) { + Napier.w("verifySdJwt: Digest of disclosure not contained in SD-JWT: $hashed") + false + } else true + }.associateWith { + runCatching { + val decoded = it.decodeToByteArray(Base64UrlStrict).decodeToString() + SelectiveDisclosureItem.deserialize(decoded).getOrThrow() + }.getOrElse { ex -> + Napier.w("verifySdJwt: Could not parse SD Item: $it", ex) return Verifier.VerifyCredentialResult.InvalidStructure(input) - .also { Napier.w("verifySdJwt: Digest of disclosure not contained in SD-JWT: $discInput") } } } + val reconstructed = ReconstructedSdJwtClaims(validDisclosures.values) val kid = sdJwtSigned.jws.header.keyId return when (parser.parseSdJwt(input, sdJwt, kid)) { - is Parser.ParseVcResult.SuccessSdJwt -> + is Parser.ParseVcResult.SuccessSdJwt -> { Verifier.VerifyCredentialResult.SuccessSdJwt( - sdJwtSigned.jws, - sdJwt, - sdJwtSigned.keyBindingJws, - sdJwtSigned.disclosures, - isRevoked + sdJwtSigned = sdJwtSigned, + verifiableCredentialSdJwt = sdJwt, + sdJwt = sdJwt, + reconstructed = reconstructed, + disclosures = validDisclosures, + isRevoked = isRevoked ).also { Napier.d("verifySdJwt: Valid") } + } else -> Verifier.VerifyCredentialResult.InvalidStructure(input) .also { Napier.d("verifySdJwt: Invalid structure from Parser") } diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt index b214af4e5..d50e8c1a7 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/VerifiablePresentationFactory.kt @@ -142,8 +142,7 @@ class VerifiablePresentationFactory( it.discloseItem(requestedRootAttributes) }.keys } - val issuerJwtPlusDisclosures = - SdJwtSigned.sdHashInput(validSdJwtCredential, filteredDisclosures) + val issuerJwtPlusDisclosures = SdJwtSigned.sdHashInput(validSdJwtCredential, filteredDisclosures) val keyBinding = createKeyBindingJws(audienceId, challenge, issuerJwtPlusDisclosures) val jwsFromIssuer = JwsSigned.deserialize(validSdJwtCredential.vcSerialized.substringBefore("~")).getOrElse { Napier.w("Could not re-create JWS from stored SD-JWT", it) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt index 6358b103d..fa1201e8a 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Verifier.kt @@ -1,11 +1,12 @@ package at.asitplus.wallet.lib.agent import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.signum.indispensable.josef.jwkId import at.asitplus.signum.indispensable.josef.toJsonWebKey import at.asitplus.wallet.lib.data.* import at.asitplus.wallet.lib.iso.IssuerSigned +import at.asitplus.wallet.lib.jws.ReconstructedSdJwtClaims +import at.asitplus.wallet.lib.jws.SdJwtSigned /** @@ -43,9 +44,12 @@ interface Verifier { sealed class VerifyPresentationResult { data class Success(val vp: VerifiablePresentationParsed) : VerifyPresentationResult() data class SuccessSdJwt( - val jwsSigned: JwsSigned, + val sdJwtSigned: SdJwtSigned, + val verifiableCredentialSdJwt: VerifiableCredentialSdJwt, + @Deprecated("Renamed to verifiableCredentialSdJwt", replaceWith = ReplaceWith("verifiableCredentialSdJwt")) val sdJwt: VerifiableCredentialSdJwt, - val disclosures: List, + val reconstructed: ReconstructedSdJwtClaims, + val disclosures: Collection, val isRevoked: Boolean ) : VerifyPresentationResult() @@ -57,16 +61,13 @@ interface Verifier { sealed class VerifyCredentialResult { data class SuccessJwt(val jws: VerifiableCredentialJws) : VerifyCredentialResult() data class SuccessSdJwt( - /** - * Extracted JWS from the input (containing also the disclosures) - */ - val jwsSigned: JwsSigned, + val sdJwtSigned: SdJwtSigned, + val verifiableCredentialSdJwt: VerifiableCredentialSdJwt, + @Deprecated("Renamed to verifiableCredentialSdJwt", replaceWith = ReplaceWith("verifiableCredentialSdJwt")) val sdJwt: VerifiableCredentialSdJwt, - val keyBindingJws: JwsSigned?, - /** - * Map of original serialized disclosure item to parsed item - */ - val disclosures: Map, + val reconstructed: ReconstructedSdJwtClaims, + /** Map of serialized disclosure item (as [String]) to parsed item (as [SelectiveDisclosureItem]) */ + val disclosures: Map, val isRevoked: Boolean, ) : VerifyCredentialResult() @@ -94,4 +95,4 @@ fun CryptoPublicKey.matchesIdentifier(input: String): Boolean { return false } -class VerificationError(message: String?): Throwable(message) \ No newline at end of file +class VerificationError(message: String?) : Throwable(message) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialSdJwt.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialSdJwt.kt index 7f99b09e5..1a21752b1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialSdJwt.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/VerifiableCredentialSdJwt.kt @@ -1,11 +1,14 @@ package at.asitplus.wallet.lib.data import at.asitplus.KmmResult.Companion.wrap +import at.asitplus.signum.indispensable.josef.ConfirmationClaim import at.asitplus.signum.indispensable.josef.JsonWebKey import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement /** * SD-JWT representation of a [VerifiableCredential]. @@ -74,7 +77,7 @@ data class VerifiableCredentialSdJwt( * See (I-D.looker-oauth-jwt-cwt-status-list) for more information. */ @SerialName("status") - // TODO Implement correct draft + // TODO Implement correct draft: draft-ietf-oauth-status-list-05 val credentialStatus: CredentialStatus? = null, @SerialName("_sd_alg") @@ -88,9 +91,21 @@ data class VerifiableCredentialSdJwt( * the key identified in this claim. */ @SerialName("cnf") - val confirmationKey: JsonWebKey? = null, + // Should be [ConfirmationClaim], but was [JsonWebKey] in our previous implementation... + val cnfElement: JsonElement? = null, ) { + @Deprecated("Use confirmationClaim", replaceWith = ReplaceWith("confirmationClaim.jsonWebKey")) + val confirmationKey: JsonWebKey? + get() = cnfElement?.let { + runCatching { vckJsonSerializer.decodeFromJsonElement(it) }.getOrNull() + } + + val confirmationClaim: ConfirmationClaim? + get() = cnfElement?.let { + runCatching { vckJsonSerializer.decodeFromJsonElement(it) }.getOrNull() + } + fun serialize() = vckJsonSerializer.encodeToString(this) companion object { diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/ReconstructedSdJwtClaims.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/ReconstructedSdJwtClaims.kt new file mode 100644 index 000000000..6f92e3aa4 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/ReconstructedSdJwtClaims.kt @@ -0,0 +1,10 @@ +package at.asitplus.wallet.lib.jws + +import at.asitplus.wallet.lib.data.SelectiveDisclosureItem + +/** + * Contains all claims that have been successfully reconstructed from an [SdJwtSigned] + */ +data class ReconstructedSdJwtClaims( + val claims: Collection +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt index 2742094a5..a7cbaa130 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/jws/SdJwtSigned.kt @@ -1,25 +1,24 @@ package at.asitplus.wallet.lib.jws -import at.asitplus.signum.indispensable.io.Base64UrlStrict +import at.asitplus.KmmResult import at.asitplus.signum.indispensable.josef.JwsSigned import at.asitplus.wallet.lib.agent.SubjectCredentialStore import at.asitplus.wallet.lib.data.KeyBindingJws import at.asitplus.wallet.lib.data.SelectiveDisclosureItem +import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import io.github.aakira.napier.Napier -import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray /** - * Representation of a signed SD-JWT (payload of [jws] is [at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt]), - * as issued by an [at.asitplus.wallet.lib.agent.Issuer] or an [at.asitplus.wallet.lib.agent.Holder], - * i.e. consisting of an JWS (with header, payload and signature) + * Representation of a signed SD-JWT, + * as issued by an [at.asitplus.wallet.lib.agent.Issuer] or presented by an [at.asitplus.wallet.lib.agent.Holder], i.e. + * consisting of an JWS (with header, payload is [at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt] and signature) * and several disclosures ([SelectiveDisclosureItem]) separated by a `~`, * possibly ending with a [keyBindingJws], that is a JWS with payload [KeyBindingJws]. */ data class SdJwtSigned( val jws: JwsSigned, - val disclosures: Map, - val keyBindingJws: JwsSigned? = null, val rawDisclosures: List, + val keyBindingJws: JwsSigned? = null, ) { override fun equals(other: Any?): Boolean { @@ -29,21 +28,21 @@ data class SdJwtSigned( other as SdJwtSigned if (jws != other.jws) return false - if (disclosures != other.disclosures) return false - if (keyBindingJws != other.keyBindingJws) return false if (rawDisclosures != other.rawDisclosures) return false + if (keyBindingJws != other.keyBindingJws) return false return true } override fun hashCode(): Int { var result = jws.hashCode() - result = 31 * result + disclosures.hashCode() - result = 31 * result + (keyBindingJws?.hashCode() ?: 0) result = 31 * result + rawDisclosures.hashCode() + result = 31 * result + (keyBindingJws?.hashCode() ?: 0) return result } + fun getPayloadAsVerifiableCredentialSdJwt(): KmmResult = + VerifiableCredentialSdJwt.deserialize(jws.payload.decodeToString()) companion object { fun parse(input: String): SdJwtSigned? { @@ -58,17 +57,9 @@ data class SdJwtSigned( val rawDisclosures = stringListWithoutJws .filterNot { it.contains(".") } .filterNot { it.isEmpty() } - val disclosures = stringListWithoutJws.take(rawDisclosures.count()) - .associateWith { - val decoded = it.decodeToByteArray(Base64UrlStrict).decodeToString() - SelectiveDisclosureItem.deserialize(decoded).getOrElse { ex -> - Napier.w("Could not parse SD Item: $it", ex) - return null - } - } val keyBindingString = stringList.drop(1 + rawDisclosures.size).firstOrNull() val keyBindingJws = keyBindingString?.let { JwsSigned.deserialize(it).getOrNull() } - return SdJwtSigned(jws, disclosures, keyBindingJws, rawDisclosures) + return SdJwtSigned(jws, rawDisclosures, keyBindingJws) } fun serializePresentation( diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt index 0e2287a72..fa12573c9 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/agent/AgentSdJwtTest.kt @@ -18,7 +18,8 @@ import at.asitplus.wallet.lib.jws.SdJwtSigned import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.kotest.core.spec.style.FreeSpec -import io.kotest.inspectors.forAll +import io.kotest.matchers.collections.shouldBeSingleton +import io.kotest.matchers.collections.shouldHaveSingleElement import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf @@ -65,10 +66,12 @@ class AgentSdJwtTest : FreeSpec({ val verified = verifier.verifyPresentation(vp.sdJwt, challenge) .shouldBeInstanceOf() - verified.disclosures shouldHaveSize 2 + verified.reconstructed.claims shouldHaveSize 2 - verified.disclosures.first { it.claimName == CLAIM_GIVEN_NAME }.claimValue.content shouldBe "Susanne" - verified.disclosures.first { it.claimName == CLAIM_DATE_OF_BIRTH }.claimValue.content shouldBe "1990-01-01" + verified.reconstructed.claims.first { it.claimName == CLAIM_GIVEN_NAME } + .claimValue.content shouldBe "Susanne" + verified.reconstructed.claims.first { it.claimName == CLAIM_DATE_OF_BIRTH } + .claimValue.content shouldBe "1990-01-01" verified.isRevoked shouldBe false } @@ -84,8 +87,8 @@ class AgentSdJwtTest : FreeSpec({ ).sdJwt val verified = verifier.verifyPresentation(sdJwt, challenge) .shouldBeInstanceOf() - verified.disclosures shouldHaveSize 1 - verified.disclosures.forAll { it.claimName shouldBe CLAIM_GIVEN_NAME } + verified.reconstructed.claims.shouldBeSingleton() + verified.reconstructed.claims.shouldHaveSingleElement { it.claimName == CLAIM_GIVEN_NAME } verified.isRevoked shouldBe false }