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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ Release 6.0.0 (unreleased):
- Update Bouncy Castle 1.84
- Update to kotlinx.coroutines 1.11.0
- Matrix testing
- Add `LoTEFilterService` for extracting trust list certificates from `LoTE` based on `ServiceTypeIdentifier`
- Add signature and time validity checks of certificate against the trust list
- Add JAdES B-B validation (Used when fetching LoTE)
- Add `issuer` property in `StoreEntry`, for evaluation of trust against trust list

Release 5.12.0:
- W3C JWT VC:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.io.encoding.Base64

class EtsiX509CertificateSerializer : KSerializer<X509Certificate> {
class EtsiX509CertificateSerializer : KSerializer<X509Certificate?> {
private val delegate = EtsiX509CertificateSerializationSurrogate.serializer()
override val descriptor: SerialDescriptor
get() = SerialDescriptor(
Expand All @@ -19,8 +24,10 @@ class EtsiX509CertificateSerializer : KSerializer<X509Certificate> {

override fun serialize(
encoder: Encoder,
value: X509Certificate
value: X509Certificate?
) {
if (value == null) return encoder.encodeNull()

encoder.encodeSerializableValue(
EtsiX509CertificateSerializationSurrogate.serializer(),
EtsiX509CertificateSerializationSurrogate(
Expand All @@ -29,9 +36,16 @@ class EtsiX509CertificateSerializer : KSerializer<X509Certificate> {
)
}

override fun deserialize(decoder: Decoder) = decoder.decodeSerializableValue(
EtsiX509CertificateSerializationSurrogate.serializer(),
).value
override fun deserialize(decoder: Decoder): X509Certificate? = try {
if (decoder is JsonDecoder) {
val element = decoder.decodeJsonElement()
decoder.json.decodeFromJsonElement(delegate, element).value
} else {
decoder.decodeSerializableValue(delegate).value
}
} catch (_: Exception) {
null
}

@Serializable
private data class EtsiX509CertificateSerializationSurrogate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface OtherId
@Serializable
data class ServiceDigitalIdentity(
@SerialName(SerialNames.X509_CERTIFICATE)
val x509Certificates: List<@Serializable(with = EtsiX509CertificateSerializer::class) X509Certificate>? = null,
val x509Certificates: List<@Serializable(with = EtsiX509CertificateSerializer::class) X509Certificate?> = emptyList(),
@SerialName(SerialNames.X509_SUBJECT_NAMES)
val x509SubjectNames: List<Rfc4514DistinguishedName>? = null,
@SerialName(SerialNames.PUBLIC_KEY_VALUE)
Expand All @@ -33,18 +33,15 @@ data class ServiceDigitalIdentity(
val otherIds: List<OtherId>? = null,
) {
init {
require(x509Certificates?.isNotEmpty() != false) {
"Expected at least 1 X509Certificate, but got 0."
require( x509Certificates.isNotEmpty() || x509SKIs?.isNotEmpty() != false) {
"Expected at least 1 X509Certificate or at least 1 X509SKI, but got 0."
}
require(x509SubjectNames?.isNotEmpty() != false) {
"Expected at least 1 X509SubjectName, but got 0."
}
require(publicKeyValues?.isNotEmpty() != false) {
"Expected at least 1 PublicKeyValue, but got 0."
}
require(x509SKIs?.isNotEmpty() != false) {
"Expected at least 1 X509SKI, but got 0."
}
require(otherIds?.isNotEmpty() != false) {
"Expected at least 1 other id, but got 0."
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package at.asitplus.etsi

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class TrustListPayload(
@SerialName("LoTE")
val loTe: ListOfTrustedEntities
)
1 change: 1 addition & 0 deletions vck/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ kotlin {
dependencies {
api(project(":dif-data-classes"))
api(project(":openid-data-classes"))
api(project(":etsi-data-classes"))
api(project(":sd-jwt-type-metadata"))
commonImplementationAndApiDependencies()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import at.asitplus.openid.dcql.DCQLQuery
import at.asitplus.signum.indispensable.cosef.CoseKey
import at.asitplus.signum.indispensable.cosef.toCoseKey
import at.asitplus.signum.indispensable.pki.X509Certificate
import at.asitplus.signum.indispensable.pki.leaf
import at.asitplus.wallet.lib.agent.SubjectCredentialStore.StoreEntry
import at.asitplus.wallet.lib.data.CredentialPresentation
import at.asitplus.wallet.lib.data.CredentialPresentationRequest
Expand Down Expand Up @@ -68,7 +69,8 @@ class HolderAgent(
vc = validated.jws,
vcSerialized = credential.vcJws,
scheme = credential.scheme,
renewalInfo = renewalInfo
renewalInfo = renewalInfo,
issuer = credential.signedVcJws.jws.jwsHeader.certificateChain?.leaf?.encodeToDerOrNull()
)
}

Expand All @@ -82,7 +84,8 @@ class HolderAgent(
vcSerialized = credential.vcSdJwt,
disclosures = validated.disclosures,
scheme = credential.scheme,
renewalInfo = renewalInfo
renewalInfo = renewalInfo,
issuer = credential.signedSdJwtVc.jws.jwsHeader.certificateChain?.leaf?.encodeToDerOrNull()
)
}

Expand All @@ -92,7 +95,8 @@ class HolderAgent(
subjectCredentialStore.storeCredential(
issuerSigned = validated.issuerSigned,
scheme = credential.scheme,
renewalInfo = renewalInfo
renewalInfo = renewalInfo,
issuer = credential.issuerSigned.issuerAuth.unprotectedHeader?.certificateChain?.getOrNull(0)
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class InMemorySubjectCredentialStore : SubjectCredentialStore {
vcSerialized: String,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo?,
issuer: ByteArray?
) = SubjectCredentialStore.StoreEntry.Vc(vcSerialized, vc, scheme.schemaUri, renewalInfo)
.also { credentials += it }

Expand All @@ -26,13 +27,15 @@ class InMemorySubjectCredentialStore : SubjectCredentialStore {
disclosures: Map<String, SelectiveDisclosureItem?>,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo?,
issuer: ByteArray?
) = SubjectCredentialStore.StoreEntry.SdJwt(vcSerialized, vc, disclosures, scheme.schemaUri, renewalInfo)
.also { credentials += it }

override suspend fun storeCredential(
issuerSigned: IssuerSigned,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo?,
issuer: ByteArray?
) = SubjectCredentialStore.StoreEntry.Iso(issuerSigned, scheme.schemaUri, renewalInfo)
.also { credentials += it }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import at.asitplus.openid.OAuth2AuthorizationServerMetadata
import at.asitplus.openid.SupportedCredentialFormat
import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer
import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer
import at.asitplus.signum.indispensable.pki.X509Certificate
import at.asitplus.wallet.lib.data.AttributeIndex
import at.asitplus.wallet.lib.data.ConstantIndex
import at.asitplus.wallet.lib.data.IsoMdocFallbackCredentialScheme
Expand Down Expand Up @@ -43,6 +44,7 @@ interface SubjectCredentialStore {
vcSerialized: String,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo? = null,
issuer: ByteArray? = null
): StoreEntry

/**
Expand All @@ -58,6 +60,7 @@ interface SubjectCredentialStore {
disclosures: Map<String, SelectiveDisclosureItem?>,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo? = null,
issuer: ByteArray? = null
): StoreEntry

/**
Expand All @@ -70,6 +73,7 @@ interface SubjectCredentialStore {
issuerSigned: IssuerSigned,
scheme: ConstantIndex.CredentialScheme,
renewalInfo: CredentialRenewalInfo? = null,
issuer: ByteArray? = null
): StoreEntry

/**
Expand All @@ -87,6 +91,7 @@ interface SubjectCredentialStore {
val credentialFormat: CredentialFormatEnum
val claimFormat: ClaimFormat
val renewalInfo: CredentialRenewalInfo?
val issuer: ByteArray?

fun getFallbackScheme(): ConstantIndex.CredentialScheme?

Expand All @@ -100,6 +105,8 @@ interface SubjectCredentialStore {
override val schemaUri: String,
@SerialName("credential-renewal-info")
override val renewalInfo: CredentialRenewalInfo? = null,
@SerialName("issuer")
override val issuer: ByteArray? = null,
) : StoreEntry {
override fun getFallbackScheme(): ConstantIndex.CredentialScheme =
VcFallbackCredentialScheme(vc.vc.type.first { it != VERIFIABLE_CREDENTIAL })
Expand All @@ -121,6 +128,8 @@ interface SubjectCredentialStore {
override val schemaUri: String,
@SerialName("credential-renewal-info")
override val renewalInfo: CredentialRenewalInfo? = null,
@SerialName("issuer")
override val issuer: ByteArray? = null
) : StoreEntry {
override fun getFallbackScheme(): ConstantIndex.CredentialScheme =
SdJwtFallbackCredentialScheme(sdJwt.verifiableCredentialType)
Expand All @@ -137,6 +146,8 @@ interface SubjectCredentialStore {
override val schemaUri: String,
@SerialName("credential-renewal-info")
override val renewalInfo: CredentialRenewalInfo? = null,
@SerialName("issuer")
override val issuer: ByteArray? = null
) : StoreEntry {
override fun getFallbackScheme(): ConstantIndex.CredentialScheme? = catchingUnwrapped {
IsoMdocFallbackCredentialScheme(issuerSigned.issuerAuth.payload?.docType!!)
Expand Down Expand Up @@ -171,4 +182,4 @@ data class CredentialRenewalInfo(
val oauthMetadata: OAuth2AuthorizationServerMetadata,
val credentialFormat: SupportedCredentialFormat,
val credentialIdentifier: String,
)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package at.asitplus.wallet.lib.etsi

import at.asitplus.etsi.EtsiX509CertificateSerializer
import at.asitplus.etsi.ListOfTrustedEntities
import at.asitplus.etsi.TEName
import at.asitplus.signum.indispensable.asn1.Asn1Primitive
import at.asitplus.signum.indispensable.asn1.Asn1String
import at.asitplus.signum.indispensable.pki.AttributeTypeAndValue
import at.asitplus.signum.indispensable.pki.X509Certificate
import kotlinx.serialization.Serializable

class LoTEFilterService {

fun extractTrustedCertificates(lote: ListOfTrustedEntities, criteria: LoTEFilterCriteria): List<TrustedCertificate> {
val entities = lote.trustedEntitiesList ?: return emptyList()

return entities.flatMap { entity ->
val providerName = entity.trustedEntityInformation.teName

entity.trustedEntityServices
.filter { it.serviceInformation.serviceTypeIdentifier?.string == criteria.expectedServiceType }
.flatMap { service -> service.serviceInformation.serviceDigitalIdentity.x509Certificates }
.filter { cert -> cert?.hasMatchingOrganization(providerName) == true }
.map { cert -> TrustedCertificate(cert, providerName, criteria.expectedServiceType) }
}
}

// Checks if any organization name matches the provider's TEName
private fun X509Certificate.hasMatchingOrganization(providerName: TEName): Boolean {
val orgName = tbsCertificate.subjectName
.flatMap { it.attrsAndValues }
.filterIsInstance<AttributeTypeAndValue.Organization>()
.firstOrNull()
?.asStringOrNull() ?: return false

return providerName.any { it.value.equals(orgName, ignoreCase = true) }
}
private fun AttributeTypeAndValue.Organization.asStringOrNull(): String? = when (val element = value) {
is Asn1Primitive -> runCatching { Asn1String.decodeFromTlv(element).value }.getOrNull()
else -> element.toString()
}
}

data class TrustedCertificate(
val certificate: @Serializable(with = EtsiX509CertificateSerializer::class) X509Certificate?,
val providerName: TEName,
val serviceType: String
)

data class LoTEFilterCriteria(
val expectedServiceType: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package at.asitplus.wallet.lib.etsi

import at.asitplus.KmmResult
import at.asitplus.catching
import at.asitplus.signum.indispensable.X509SignatureAlgorithm
import at.asitplus.signum.indispensable.pki.CertificateChain
import at.asitplus.signum.indispensable.pki.X509Certificate
import at.asitplus.signum.supreme.sign.verifierFor
import at.asitplus.signum.supreme.sign.verify
import kotlin.time.Clock
import kotlin.time.Instant

/**
* Verifies if this certificate is directly signed and trusted by any anchor in the [trustStore].
* Enforces time validity, cryptographic integrity
*/
fun X509Certificate.isTrustedBy(
trustStore: CertificateChain,
date: Instant = Clock.System.now()
): KmmResult<Unit> = catching {
if (!this.isValidAt(date)) throw Exception("Certificate is not valid at $date")

trustStore
.asSequence()
.filter { it.isValidAt(date) }
.map { it.isIssuerOf(this) }
.firstOrNull { it.isSuccess }
?: throw IllegalArgumentException(
"No valid trust anchor could verify certificate"
)

}

/**
* Checks whether this certificate has expired at the specified [date].
* @return `true` if the certificate is expired, `false` otherwise.
*/
fun X509Certificate.isExpired(date: Instant = Clock.System.now()): Boolean =
Instant.fromEpochSeconds(date.epochSeconds) > tbsCertificate.validUntil.instant

/**
* Checks whether this certificate is not yet valid at the specified [date].
* @return `true` if the certificate is not yet valid, `false` otherwise.
*/
fun X509Certificate.isNotYetValid(date: Instant = Clock.System.now()): Boolean =
Instant.fromEpochSeconds(date.epochSeconds) < tbsCertificate.validFrom.instant


/**
* Checks whether this certificate is valid at the specified [date].
*/
fun X509Certificate.isValidAt(date: Instant = Clock.System.now()): Boolean = !(isExpired(date) || isNotYetValid(date))

/**
* Verifies that this certificate is the issuer of the given [cert].
*/
fun X509Certificate.isIssuerOf(cert: X509Certificate): KmmResult<Unit> = catching {
if (cert.tbsCertificate.issuerName != this.tbsCertificate.subjectName) throw Exception("Subject of issuer cert and issuer of child certificate mismatch.")

if (cert.tbsCertificate.issuerUniqueID != this.tbsCertificate.subjectUniqueID) throw Exception("UID of issuer cert and UID of issuer in child certificate mismatch.")

val verifier = (cert.signatureAlgorithm as X509SignatureAlgorithm).verifierFor(this.decodedPublicKey.getOrThrow()).getOrThrow()
verifier.verify(
cert.tbsCertificate.encodeToDer(),
cert.decodedSignature.getOrThrow()
).getOrThrow()
}
Loading
Loading