diff --git a/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt b/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt index 6efd6af..c645978 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt @@ -3,6 +3,7 @@ package app.cash.trifle import app.cash.trifle.CertificateRequest.PKCS10Request import app.cash.trifle.TrifleErrors.CSRMismatch import app.cash.trifle.internal.validators.CertChainValidatorFactory +import app.cash.trifle.internal.validators.CertificateValidatorFactory import okio.ByteString.Companion.toByteString import org.bouncycastle.cert.X509CertificateHolder import java.util.Date @@ -47,25 +48,35 @@ data class Certificate internal constructor( * - failure value is expressed as a [TrifleErrors] */ fun verify( - certificateRequest: CertificateRequest, - ancestorCertificateChain: List, - anchorCertificate: Certificate, + certificateRequest: CertificateRequest? = null, + ancestorCertificateChain: List = emptyList(), + anchorCertificate: Certificate? = null, date: Date? = null ): Result { + val rootCertificate = anchorCertificate ?: + if (ancestorCertificateChain.isNotEmpty()) { + ancestorCertificateChain.last() + } else { + return CertificateValidatorFactory.get(this).validate(this, date) + } // First check to see if the certificate chain validates - val certChainResult = CertChainValidatorFactory.get(anchorCertificate, date) + val certChainResult = CertChainValidatorFactory.get(rootCertificate, date) .validate(listOf(this) + ancestorCertificateChain) - return certChainResult.mapCatching { - val x509Certificate = X509CertificateHolder(certificate) - when (certificateRequest) { - is PKCS10Request -> { - // Certificate chain matches, check with certificate request. - // TODO(dcashman): Check other attributes as well. - if (certificateRequest.pkcs10Req.subject != x509Certificate.subject || - certificateRequest.pkcs10Req.subjectPublicKeyInfo != x509Certificate.subjectPublicKeyInfo - ) { - throw CSRMismatch + return if (certificateRequest == null) { + certChainResult + } else { + certChainResult.mapCatching { + val x509Certificate = X509CertificateHolder(certificate) + when (certificateRequest) { + is PKCS10Request -> { + // Certificate chain matches, check with certificate request. + // TODO(dcashman): Check other attributes as well. + if (certificateRequest.pkcs10Req.subject != x509Certificate.subject || + certificateRequest.pkcs10Req.subjectPublicKeyInfo != x509Certificate.subjectPublicKeyInfo + ) { + throw CSRMismatch + } } } } diff --git a/jvm/src/main/kotlin/app/cash/trifle/TrifleErrors.kt b/jvm/src/main/kotlin/app/cash/trifle/TrifleErrors.kt index 03c0efd..c2849a4 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/TrifleErrors.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/TrifleErrors.kt @@ -4,6 +4,7 @@ sealed class TrifleErrors(message: String, cause: Throwable? = null) : Exception object NoTrustAnchor : TrifleErrors("No acceptable Trifle trust anchor found") object InvalidCertPath : TrifleErrors("Invalid Trifle certificate path found") object ExpiredCertificate : TrifleErrors("Expired Trifle certificate") + object NotValidYetCertificate : TrifleErrors("Trifle certificate is not valid yet") object InvalidSignature : TrifleErrors("Invalid Trifle signature") object CSRMismatch : TrifleErrors("Trifle certificate does not match CSR") class UnspecifiedFailure(message: String, cause: Throwable) : TrifleErrors(message, cause) diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertificateValidator.kt b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertificateValidator.kt new file mode 100644 index 0000000..aff3258 --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertificateValidator.kt @@ -0,0 +1,15 @@ +package app.cash.trifle.internal.validators + +import app.cash.trifle.Certificate +import java.util.Date + +sealed interface CertificateValidator { + /** + * Validates if a specific Trifle Certificate is within the validity window of the date. + * + * @param certificate - The certificate to validate. + * @param date - The date to use for verification against certificates' validity windows. If null, + * the current time is used. + */ + fun validate(certificate: Certificate, date: Date?): Result +} diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertificateValidatorFactory.kt b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertificateValidatorFactory.kt new file mode 100644 index 0000000..c5584cb --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertificateValidatorFactory.kt @@ -0,0 +1,17 @@ +package app.cash.trifle.internal.validators + +import app.cash.trifle.Certificate + +object CertificateValidatorFactory { + /** + * Return a certificate validator matching the provided certificate. + * @param certificate - The certificate to validate. Its format will determine how + * verification should be performed. + */ + fun get(certificate: Certificate): CertificateValidator { + return when (certificate.version) { + Certificate.CERTIFICATE_VERSION -> X509CertificateValidator + else -> throw UnsupportedOperationException("Unsupported version of Trifle Certificate") + } + } +} diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertChainValidator.kt b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertChainValidator.kt index cbc6709..e3f94f9 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertChainValidator.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertChainValidator.kt @@ -6,9 +6,10 @@ import app.cash.trifle.TrifleErrors.InvalidCertPath import app.cash.trifle.TrifleErrors.InvalidSignature import app.cash.trifle.TrifleErrors.NoTrustAnchor import app.cash.trifle.TrifleErrors.UnspecifiedFailure +import app.cash.trifle.internal.validators.X509CertificateUtil.generateCertPath +import app.cash.trifle.internal.validators.X509CertificateUtil.toX509Certificate import java.security.cert.CertPathValidatorException import java.security.cert.CertPathValidatorException.BasicReason -import java.security.cert.CertificateFactory import java.security.cert.PKIXParameters import java.security.cert.PKIXReason import java.security.cert.TrustAnchor @@ -27,9 +28,7 @@ internal class X509CertChainValidator( private val pkixParams: PKIXParameters = PKIXParameters( setOf( TrustAnchor( - certAnchor.certificate.inputStream().use { - X509FACTORY.generateCertificate(it) - } as X509Certificate, + certAnchor.toX509Certificate(), null ) ) @@ -42,16 +41,12 @@ internal class X509CertChainValidator( override fun validate(certChain: List): Result = try { - val x509Certs = certChain.map { trifleCert -> - trifleCert.certificate.inputStream().use { - X509FACTORY.generateCertificate(it) as X509Certificate - } - }.maybeDropRoot() + val x509Certs = certChain.map { it.toX509Certificate() }.maybeDropRoot() if (x509Certs.isEmpty()) { Result.failure(InvalidCertPath) } else { - PATH_VALIDATOR.validate(X509FACTORY.generateCertPath(x509Certs), pkixParams) + PATH_VALIDATOR.validate(x509Certs.generateCertPath(), pkixParams) Result.success(Unit) } } catch (e: CertPathValidatorException) { @@ -86,6 +81,5 @@ internal class X509CertChainValidator( private val PATH_VALIDATOR = JCACertPathValidator.getInstance( JCACertPathValidator.getDefaultType() ) - private val X509FACTORY = CertificateFactory.getInstance("X509") } } diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertificateUtil.kt b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertificateUtil.kt new file mode 100644 index 0000000..077405e --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertificateUtil.kt @@ -0,0 +1,17 @@ +package app.cash.trifle.internal.validators + +import app.cash.trifle.Certificate +import java.security.cert.CertPath +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +internal object X509CertificateUtil { + internal fun Certificate.toX509Certificate() = certificate.inputStream().use { + X509FACTORY.generateCertificate(it) as X509Certificate + } + + internal fun List.generateCertPath(): CertPath = + X509FACTORY.generateCertPath(this) + + private val X509FACTORY = CertificateFactory.getInstance("X509") +} \ No newline at end of file diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertificateValidator.kt b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertificateValidator.kt new file mode 100644 index 0000000..72dd079 --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertificateValidator.kt @@ -0,0 +1,23 @@ +package app.cash.trifle.internal.validators + +import app.cash.trifle.Certificate +import app.cash.trifle.TrifleErrors +import app.cash.trifle.internal.validators.X509CertificateUtil.toX509Certificate +import java.security.cert.CertificateExpiredException +import java.security.cert.CertificateNotYetValidException +import java.util.Date + +/** + * X.509 specific implementation for validating a certificate. + */ +internal object X509CertificateValidator : CertificateValidator { + override fun validate(certificate: Certificate, date: Date?): Result = + try { + certificate.toX509Certificate().checkValidity(date ?: Date()) + Result.success(Unit) + } catch (e: CertificateExpiredException) { + Result.failure(TrifleErrors.ExpiredCertificate) + } catch (e: CertificateNotYetValidException) { + Result.failure(TrifleErrors.NotValidYetCertificate) + } +} diff --git a/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt b/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt index d625dab..4f98747 100644 --- a/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt +++ b/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt @@ -23,6 +23,20 @@ internal class CertificateTests { assertTrue(result.isSuccess) } + @Test + fun `test verify() succeeds for a stored issued certificate with intermediate chain`() { + val result = endEntity.certificate.verify( + ancestorCertificateChain = endEntity.certChain.drop(1) + ) + assertTrue(result.isSuccess) + } + + @Test + fun `test verify() succeeds for a stored issued certificate`() { + val result = endEntity.certificate.verify() + assertTrue(result.isSuccess) + } + @Test fun `test verify() fails with CSRMismatch for a legitimate certificate from the same CA, but wrong entity`() { val result = endEntity.certificate.verify( @@ -56,6 +70,24 @@ internal class CertificateTests { assertTrue(result.isFailure) assertTrue(result.exceptionOrNull() is ExpiredCertificate) } + + @Test + fun `test verify() fails with ExpiredCertificate for an expired stored certificate`() { + val result = endEntity.certificate.verify( + date = Date.from(Instant.now().plus(Duration.ofDays(365))) + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is ExpiredCertificate) + } + + @Test + fun `test verify() fails with NotYetValidCertificate for a stored certificate yet to be valid`() { + val result = endEntity.certificate.verify( + date = Date.from(Instant.now().minus(Duration.ofDays(1))) + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is NotValidYetCertificate) + } } companion object {