Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,31 @@ sealed class CryptoException(message: String? = null, cause: Throwable? = null)
open class CryptoOperationFailed(message: String) : CryptoException(message)
open class UnsupportedCryptoException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause)

sealed class CertificateException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)
open class CertificateException(message: String? = null, cause: Throwable? = null) : Throwable(message, cause)
class CertificateChainValidatorException(message: String) : CertificateException(message)
sealed class CertificateValidityException(message: String) : CertificateException(message)
class CertificateSerialNumberException(message: String) : CertificateValidityException(message)
class SanNotCriticalWithEmptySubjectException(message: String) : CertificateValidityException(message)
class KeyUsageException(message: String) : CertificateException(message)
class ExtendedKeyUsageException(message: String) : CertificateException(message)
class CertificateValidityException(message: String) : CertificateException(message)
class BasicConstraintsException(message: String) : CertificateException(message)
sealed class CertificateTimeValidityException(message: String) : CertificateException(message)
class CertificateNotYetValidException(message: String) : CertificateTimeValidityException(message)
class CertificateExpiredException(message: String) : CertificateTimeValidityException(message)
class InvalidCertificateValidityPeriodException(message: String) : CertificateTimeValidityException(message)
sealed class BasicConstraintsException(message: String) : CertificateException(message)
class MissingBasicConstraintsException(message: String) : BasicConstraintsException(message)
class NonCriticalBasicConstraintsException(message: String) : BasicConstraintsException(message)
class MissingCaFlagException(message: String) : BasicConstraintsException(message)
class PathLenConstraintViolationException(message: String) : BasicConstraintsException(message)

class NameConstraintsException(message: String) : CertificateException(message)
class GeneralNameException(message: String) : CertificateException(message)
class CertificatePolicyException(message: String) : CertificateException(message)
sealed class TrustAnchorException(message: String) : CertificateException(message)
class NoTrustedIssuerFoundException(message: String) : TrustAnchorException(message)
class TrustAnchorKeyMismatchException(message: String) : TrustAnchorException(message)
sealed class KeyIdentifierException(message: String) : CertificateException(message)
class MissingSubjectKeyIdentifierException(message: String) : KeyIdentifierException(message)
class CriticalSubjectKeyIdentifierException(message: String) : KeyIdentifierException(message)
class MissingAuthorityKeyIdentifierException(message: String) : KeyIdentifierException(message)
class CriticalAuthorityKeyIdentifierException(message: String) : KeyIdentifierException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package at.asitplus.signum.indispensable.pki

import at.asitplus.catching
import at.asitplus.catchingUnwrapped
import at.asitplus.signum.CertificateExpiredException
import at.asitplus.signum.CertificateNotYetValidException
import at.asitplus.signum.CertificateValidityException
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.CryptoSignature
Expand All @@ -21,6 +23,8 @@ import at.asitplus.signum.indispensable.pki.generalNames.X500Name
import io.matthewnelson.encoding.base64.Base64
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.Transient
import kotlinx.serialization.builtins.serializer
import kotlin.time.Clock
Expand Down Expand Up @@ -344,7 +348,26 @@ data class X509Certificate @Throws(IllegalArgumentException::class) constructor(
/**
* Checks whether this certificate is valid at the specified [date].
*/
fun isValidAt(date: Instant = Clock.System.now()): Boolean = !(isExpired(date) || isNotYetValid(date))
fun isValidAt(date: Instant = Clock.System.now()): Boolean = runCatching { checkValidityAt(date) }.isSuccess

@Throws(CertificateValidityException::class)
fun checkValidityAt(date: Instant = Clock.System.now()) {
if (isExpired(date)) {
throw CertificateExpiredException(
"certificate expired on " +
tbsCertificate.validUntil.instant
.toLocalDateTime(TimeZone.UTC)
)
}

if (isNotYetValid(date)) {
throw CertificateNotYetValidException(
"certificate not valid till " +
tbsCertificate.validFrom.instant
.toLocalDateTime(TimeZone.UTC)
)
}
}

val rawPublicKey get() = tbsCertificate.rawPublicKey
val decodedPublicKey get() = tbsCertificate.decodedPublicKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import at.asitplus.signum.indispensable.asn1.Asn1Sequence
import at.asitplus.signum.indispensable.asn1.Asn1StructuralException
import at.asitplus.signum.indispensable.asn1.KnownOIDs
import at.asitplus.signum.indispensable.asn1.ObjectIdentifier
import at.asitplus.signum.indispensable.asn1.TagClass
import at.asitplus.signum.indispensable.asn1.authorityKeyIdentifier_2_5_29_35
import at.asitplus.signum.indispensable.asn1.decodeRethrowing
import at.asitplus.signum.indispensable.asn1.encoding.decode
Expand Down Expand Up @@ -42,11 +43,14 @@ class AuthorityKeyIdentifierExtension(
val inner = base.value.asEncapsulatingOctetString().single().asSequence()

val (keyIdentifier, authorityCertIssuer, pathLenConstraint) = inner.decodeRethrowing {
val keyIdentifier = nextOrNull()?.asPrimitive()?.content
val authorityCertIssuer: List<GeneralName> = nextOrNull()?.asSequence()?.children
?.map { GeneralName.decodeFromTlv(it.asSequence()) }
val contents = listOfNotNull(nextOrNull(), nextOrNull(), nextOrNull())
val keyIdentifier = contents.firstOrNull { it.tag.tagValue == 0uL }?.asPrimitive()
?.decode(Asn1Element.Tag(0UL, constructed = false, tagClass = TagClass.CONTEXT_SPECIFIC)) { it }
val authorityCertIssuer = contents.firstOrNull { it.tag.tagValue == 1uL }?.asExplicitlyTagged()?.children
?.map { GeneralName.decodeFromTlv(it) }
?: emptyList()
val authorityCertSerialNumber = nextOrNull()?.asPrimitive()?.decode(Asn1Element.Tag.INT) { it }
val authorityCertSerialNumber = contents.firstOrNull { it.tag.tagValue == 2uL }?.asPrimitive()
?.decode(Asn1Element.Tag(2UL, constructed = false, tagClass = TagClass.CONTEXT_SPECIFIC)) { it }
Triple(keyIdentifier, authorityCertIssuer, authorityCertSerialNumber)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import at.asitplus.signum.indispensable.asn1.Asn1ExplicitlyTagged
import at.asitplus.signum.indispensable.asn1.Asn1Integer
import at.asitplus.signum.indispensable.asn1.Asn1Sequence
import at.asitplus.signum.indispensable.asn1.Asn1String
import at.asitplus.signum.indispensable.asn1.Asn1StructuralException
import at.asitplus.signum.indispensable.asn1.decodeRethrowing
import at.asitplus.signum.indispensable.asn1.encoding.Asn1
import at.asitplus.signum.indispensable.asn1.encoding.parse
Expand Down Expand Up @@ -39,17 +40,20 @@ data class GeneralSubtree(
override fun doDecode(src: Asn1Sequence): GeneralSubtree = src.decodeRethrowing {
val base = GeneralName.decodeFromTlv(next())
var minimum = Asn1Integer(0)
if (hasNext()) {
minimum = Asn1Integer.decodeFromTlv(next().asPrimitive())
var maximum: Asn1Integer? = null

while (hasNext()) {
val child = next()
when (child.tag.tagValue) {
0uL -> minimum = Asn1Integer.decodeFromTlv(child.asPrimitive())
1uL -> maximum = Asn1Integer.decodeFromTlv(child.asPrimitive())
else -> throw Asn1StructuralException("Unexpected tag in GeneralSubtree: ${child.tag}")
}
}

return if (!hasNext()) GeneralSubtree(
GeneralSubtree(
base = base,
minimum = minimum
) else GeneralSubtree(
base,
minimum,
Asn1Integer.decodeFromTlv(next().asPrimitive())
minimum = minimum,
maximum = maximum
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ package at.asitplus.signum.indispensable.pki.validate

import at.asitplus.signum.BasicConstraintsException
import at.asitplus.signum.ExperimentalPkiApi
import at.asitplus.signum.MissingBasicConstraintsException
import at.asitplus.signum.MissingCaFlagException
import at.asitplus.signum.NonCriticalBasicConstraintsException
import at.asitplus.signum.PathLenConstraintViolationException
import at.asitplus.signum.indispensable.asn1.*
import at.asitplus.signum.indispensable.asn1.ObjectIdentifier
import at.asitplus.signum.indispensable.pki.X509Certificate
Expand All @@ -17,20 +21,20 @@ class BasicConstraintsValidator(
) : CertificateValidator {

@ExperimentalPkiApi
override suspend fun check(currCert: X509Certificate, remainingCriticalExtensions: MutableSet<ObjectIdentifier>) {
remainingCriticalExtensions.remove(KnownOIDs.basicConstraints_2_5_29_19)
override suspend fun check(currCert: X509Certificate, checkedCriticalExtensions: MutableSet<ObjectIdentifier>) {
checkedCriticalExtensions.add(KnownOIDs.basicConstraints_2_5_29_19)
if (currentCertIndex >= certPathLen - 1) return

currentCertIndex++

val basicConstraints = currCert.findExtension<BasicConstraintsExtension>()
?: throw BasicConstraintsException("Missing basicConstraints extension at cert index $currentCertIndex.")
?: throw MissingBasicConstraintsException("Missing basicConstraints extension at cert index $currentCertIndex.")

checkCaBasicConstraints(currCert)
checkCaBasicConstraints(currCert, currentCertIndex)

if (remainingPathLength != null && !currCert.isSelfIssued) {
if (remainingPathLength?.toInt() == 0) {
throw BasicConstraintsException("pathLenConstraint violated at cert index $currentCertIndex.")
throw PathLenConstraintViolationException("pathLenConstraint violated at cert index $currentCertIndex.")
}
remainingPathLength = remainingPathLength?.minus(1u)
}
Expand All @@ -41,18 +45,19 @@ class BasicConstraintsValidator(
}
}
}
}

@Throws(BasicConstraintsException::class)
fun checkCaBasicConstraints(cert: X509Certificate) {
val basicConstraints = cert.findExtension<BasicConstraintsExtension>()
?: throw BasicConstraintsException("Missing basicConstraints extension at cert index $currentCertIndex.")
@Throws(BasicConstraintsException::class)
fun checkCaBasicConstraints(cert: X509Certificate, certIndex: Int? = null) {
val location = certIndex?.let { "at cert index $it." } ?: "at trust anchor"
val basicConstraints = cert.findExtension<BasicConstraintsExtension>()
?: throw MissingBasicConstraintsException("Missing basicConstraints extension $location")

if(!basicConstraints.critical) {
throw BasicConstraintsException("basicConstraints extension must be critical (index $currentCertIndex).")
}
if(!basicConstraints.critical) {
throw NonCriticalBasicConstraintsException("basicConstraints extension must be critical $location")
}

if (!basicConstraints.ca) {
throw BasicConstraintsException("Missing CA flag at cert index $currentCertIndex.")
}
if (!basicConstraints.ca) {
throw MissingCaFlagException("Missing CA flag $location")
}
}
Original file line number Diff line number Diff line change
@@ -1,53 +1,47 @@
package at.asitplus.signum.indispensable.pki.validate

import at.asitplus.signum.CertificateChainValidatorException
import at.asitplus.signum.CertificateValidityException
import at.asitplus.signum.CertificateSerialNumberException
import at.asitplus.signum.ExperimentalPkiApi
import at.asitplus.signum.indispensable.asn1.Asn1StructuralException
import at.asitplus.signum.InvalidCertificateValidityPeriodException
import at.asitplus.signum.SanNotCriticalWithEmptySubjectException
import at.asitplus.signum.indispensable.asn1.KnownOIDs
import at.asitplus.signum.indispensable.asn1.ObjectIdentifier
import at.asitplus.signum.indispensable.asn1.subjectAltName_2_5_29_17
import at.asitplus.signum.indispensable.pki.X509Certificate
import at.asitplus.signum.indispensable.pki.pkiExtensions.AuthorityKeyIdentifierExtension
import at.asitplus.signum.indispensable.pki.pkiExtensions.SubjectKeyIdentifierExtension
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant

/**
* Checks whether the certificate is constructed correctly, since some components are decoded too leniently
* */
class CertValidityValidator(
val date: Instant
) : CertificateValidator {
class CertValidityValidator: CertificateValidator {
@ExperimentalPkiApi
override suspend fun check(
currCert: X509Certificate,
remainingCriticalExtensions: MutableSet<ObjectIdentifier>
checkedCriticalExtensions: MutableSet<ObjectIdentifier>
) {
checkedCriticalExtensions.add(KnownOIDs.subjectAltName_2_5_29_17)
checkSerialNumber(currCert)
isSanCriticalWhenNameIsEmpty(currCert)
checkTimeValidity(currCert)
}

@Throws(Asn1StructuralException::class)
@Throws(CertificateSerialNumberException::class)
fun checkSerialNumber(cert: X509Certificate) {
if (cert.tbsCertificate.serialNumber.size > 20) throw Asn1StructuralException("Serial number too long")
if (cert.tbsCertificate.serialNumber[0] < 0) throw Asn1StructuralException("Serial number must be positive")
if (cert.tbsCertificate.serialNumber.all { it == 0.toByte() }) throw Asn1StructuralException("Serial number must not be zero")
if (cert.tbsCertificate.serialNumber.size > 20) throw CertificateSerialNumberException("Serial number too long")
if (cert.tbsCertificate.serialNumber[0] < 0) throw CertificateSerialNumberException("Serial number must be positive")
if (cert.tbsCertificate.serialNumber.all { it == 0.toByte() }) throw CertificateSerialNumberException("Serial number must not be zero")
}

@Throws(CertificateChainValidatorException::class)
@Throws(SanNotCriticalWithEmptySubjectException::class)
private fun isSanCriticalWhenNameIsEmpty(cert: X509Certificate) {
val sanExtension = cert.tbsCertificate.extensions?.find { it.oid == KnownOIDs.subjectAltName_2_5_29_17 }
if (cert.tbsCertificate.subjectName.relativeDistinguishedNames.isEmpty() && sanExtension?.critical == false)
throw CertificateChainValidatorException("SAN extension is not critical, which is required when subject is empty.")
throw SanNotCriticalWithEmptySubjectException("SAN extension is not critical, which is required when subject is empty.")

}

@Throws(Asn1StructuralException::class)
@Throws(InvalidCertificateValidityPeriodException::class)
private fun checkTimeValidity(cert: X509Certificate) {
if (cert.tbsCertificate.validFrom.instant > cert.tbsCertificate.validUntil.instant)
throw Asn1StructuralException("notBefore is later then notAfter.")
throw InvalidCertificateValidityPeriodException("notBefore is later then notAfter.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ import at.asitplus.signum.indispensable.pki.X509Certificate
import kotlin.coroutines.cancellation.CancellationException

interface CertificateValidator {
// Every validator removes checked critical extensions

/**
* Performs certificate validation for the given certificate
* Every validator adds checked critical extensions
*
* @throws CertificateException If the certificate fails validation according to the rules implemented by this validator
* @throws CancellationException
* @throws Throwable For multiplatform safety (e.g., Kotlin/Native to Swift), this allows catching all exceptions without crashing the application.
*/
@ExperimentalPkiApi
@Throws(CertificateException::class, CancellationException::class)
suspend fun check(currCert: X509Certificate, remainingCriticalExtensions: MutableSet<ObjectIdentifier>)
@Throws(Throwable::class)
suspend fun check(currCert: X509Certificate, checkedCriticalExtensions: MutableSet<ObjectIdentifier>)
}
Loading