diff --git a/README.md b/README.md index fe33f60..7df7cb9 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ trifleApi.delete(keyHandle) // from backup, ... etc) // Create cert request with an entity identity that is associated with the public key +// Once you receive the certificate, you can delete the cert request. val certReq: CertificateRequest = TrifleApi.generateMobileCertificateRequest(entity, keyHandle) // Serialize as an opaque ByteArray @@ -134,20 +135,17 @@ val certs: List = response.map { Certificate.deserialize(it) } // and the rest of the elements will be intermediate chain. // Check if app has the root cert of Certificate Authority (CA). +// Verify that the cert chain validates OK (i.e., it has been generated by the right CA). +val result: Result = TrifleApi.verifyChain(certs, root) -// Validate cert matches the certificate request (so generated key) -// and the root (so it has been generated by the right CA). -val result: Result = TrifleApi.verify(certs[0], certReq, certs, root) - -// Once it passes validation, certReq is no longer needed, and it can be deleted // Store cert along with the respective keyHandle // To check only for the validity of a stored cert, you can do either of below choices // Option 1 is a more complete check of the device cert and the full chain -val result: Result = TrifleApi.verify(cert, ancestorCertificateChain = certs.drop(1)) +val result: Result = TrifleApi.verifyChain(certs) // option 2 only checks the validity of the device cert -val result: Result = TrifleApi.verify(cert) +val result: Result = TrifleApi.verifyValidity(cert) // Error handling from the Result.isFailure can be found in the enumeration in TrifleErrors if (result.isFailure) { diff --git a/android/trifle/build.gradle.kts b/android/trifle/build.gradle.kts index e044e9a..04a5594 100644 --- a/android/trifle/build.gradle.kts +++ b/android/trifle/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("junit:junit:4.13.2") + androidTestImplementation(project(":jvm-testing")) api(project(":jvm")) } diff --git a/android/trifle/src/androidTest/java/app/cash/trifle/TrifleApiTest.kt b/android/trifle/src/androidTest/java/app/cash/trifle/TrifleApiTest.kt index 20b3a22..58b5c0e 100644 --- a/android/trifle/src/androidTest/java/app/cash/trifle/TrifleApiTest.kt +++ b/android/trifle/src/androidTest/java/app/cash/trifle/TrifleApiTest.kt @@ -1,5 +1,10 @@ package app.cash.trifle +import app.cash.trifle.TrifleErrors.CSRMismatch +import app.cash.trifle.TrifleErrors.ExpiredCertificate +import app.cash.trifle.TrifleErrors.NoTrustAnchor +import app.cash.trifle.TrifleErrors.NotValidYetCertificate +import app.cash.trifle.testing.TestCertificateAuthority import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue @@ -7,7 +12,9 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.ExpectedException - +import java.time.Duration +import java.time.Instant +import java.util.Date class TrifleApiTest { @JvmField @@ -35,4 +42,85 @@ class TrifleApiTest { @Test fun testDeleteKeyHandle() { TrifleApi.delete(keyHandle) } + + @Test + fun testVerify_succeeds() { + val result = TrifleApi.verifyChain( + certificateChain = endEntity.certChain + ) + assertTrue(result.isSuccess) + } + + @Test + fun testVerifyCertificateValidity_succeeds() { + val result = TrifleApi.verifyValidity( + endEntity.certificate + ) + assertTrue(result.isSuccess) + } + + @Test + fun testVerifyAttributes_succeeds() { + val result = TrifleApi.verifyCertRequestResponse( + endEntity.certificate, + endEntity.certRequest + ) + assertTrue(result.isSuccess) + } + + @Test + fun testVerify_failsWithNoTrustAnchorForADifferentRootCertificate() { + val result = TrifleApi.verifyChain( + certificateChain = listOf(endEntity.certificate) + otherEndEntity.certChain.drop(1) + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is NoTrustAnchor) + } + + @Test + fun testVerify_failsWithExpiredCertificateForAnExpiredCertificate() { + val result = TrifleApi.verifyChain( + certificateChain = endEntity.certChain, + date = Date.from(Instant.now().plus(Duration.ofDays(365))) + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is ExpiredCertificate) + } + + @Test + fun testVerify_failsWithExpiredCertificateForAnExpiredStoredCertificate() { + val result = TrifleApi.verifyValidity( + endEntity.certificate, + Date.from(Instant.now().plus(Duration.ofDays(365))) + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is ExpiredCertificate) + } + + @Test + fun testVerify_failsWithNotYetValidCertificateForAStoredCertificateYetToBeValid() { + val result = TrifleApi.verifyValidity( + endEntity.certificate, + date = Date.from(Instant.now().minus(Duration.ofDays(1))) + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is NotValidYetCertificate) + } + + @Test + fun testVerifyAttributes_failsWithCSRMismatch() { + val result = TrifleApi.verifyCertRequestResponse( + endEntity.certificate, + otherEndEntity.certRequest + ) + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is CSRMismatch) + } + + companion object { + private val certificateAuthority = TestCertificateAuthority("issuingEntity") + private val otherCertificateAuthority = TestCertificateAuthority("otherIssuingEntity") + private val endEntity = certificateAuthority.createTestEndEntity("entity") + private val otherEndEntity = otherCertificateAuthority.createTestEndEntity("otherEntity") + } } diff --git a/android/trifle/src/main/java/app/cash/trifle/TrifleApi.kt b/android/trifle/src/main/java/app/cash/trifle/TrifleApi.kt index 710873e..88ef0c7 100644 --- a/android/trifle/src/main/java/app/cash/trifle/TrifleApi.kt +++ b/android/trifle/src/main/java/app/cash/trifle/TrifleApi.kt @@ -1,5 +1,9 @@ package app.cash.trifle +import app.cash.trifle.validators.CertChainValidatorFactory +import app.cash.trifle.validators.CertificateValidatorFactory +import java.util.Date + object TrifleApi { /** * Create a new mobile Trifle keypair for which can be used to create a @@ -50,7 +54,8 @@ object TrifleApi { * * @param data - raw data to be signed. * @param keyHandle - key handle used for the signing. - * @param certificates - certificate chain to be included in the SignedData message. Must match the key in keyHandle. + * @param certificates - certificate chain to be included in the SignedData message. + * Must match the key in keyHandle. * * @return A signed data message in the Trifle format [SignedData]. */ @@ -59,4 +64,61 @@ object TrifleApi { keyHandle: KeyHandle, certificates: List ): SignedData = Trifle.EndEntity(keyHandle.keyPair).createSignedData(data, certificates) + + /** + * Verify that the provided Trifle Certificate Chain is valid. + * + * @param certificateChain - list of certificates. Namely, the first + * entry should be the certificate corresponding to the subject, and the subsequent being + * the issuer of the former certificate, and each thereafter should be the issuer of the + * one before it. + * @param anchorCertificate - the trust anchor against which we would like to verify the + * certificateChain instead. This may be the terminal (root) certificate of the chain or may be + * an intermediate certificate in the chain which is already trusted. + * @param date - The date to use for verification against certificates' validity windows. If null, + * the current time is used. + * + * @return - [Result] indicating [Result.isSuccess] or [Result.isFailure]: + * - success value is expressed as a [Unit] (Nothing) + * - failure value is expressed as a [TrifleErrors] + */ + fun verifyChain( + certificateChain: List, + anchorCertificate: Certificate? = null, + date: Date? = null + ): Result = CertChainValidatorFactory.get( + certAnchor = anchorCertificate ?: certificateChain.last(), + date = date + ).validate(certificateChain) + + /** + * Verify that the provided Trifle Certificate is valid. + * + * @param certificate - the certificate to verify + * @param date - The date to use for verification against certificate' validity windows. If null, + * the current time is used. + * + * @return - [Result] indicating [Result.isSuccess] or [Result.isFailure]: + * - success value is expressed as a [Unit] (Nothing) + * - failure value is expressed as a [TrifleErrors] + */ + fun verifyValidity( + certificate: Certificate, + date: Date? = null + ): Result = CertificateValidatorFactory.get(certificate).validate(date) + + /** + * Verify that the provided Trifle Certificate matches the Certificate Requests' attributes. + * + * @param certificate - the certificate to verify + * @param certificateRequest - request used to generate this certificate + * + * @return - [Result] indicating [Result.isSuccess] or [Result.isFailure]: + * - success value is expressed as a [Unit] (Nothing) + * - failure value is expressed as a [TrifleErrors] + */ + fun verifyCertRequestResponse( + certificate: Certificate, + certificateRequest: CertificateRequest + ): Result = CertificateValidatorFactory.get(certificate).validate(certificateRequest) } diff --git a/ios/Example/Tests/CertificateTests.swift b/ios/Example/Tests/CertificateTests.swift index 1f24ee8..7c338a4 100644 --- a/ios/Example/Tests/CertificateTests.swift +++ b/ios/Example/Tests/CertificateTests.swift @@ -16,8 +16,8 @@ final class CertificateTests: XCTestCase { let mobileCertReq = try trifle.generateMobileCertificateRequest(entity: "trifleEntity", keyHandle: keyHandle) - let deviceCertificate = try TrifleCertificate.deserialize(data: TestFixtures.deviceTrifleCertEncoded2!).getCertificate() - let rootCertificate = try TrifleCertificate.deserialize(data: TestFixtures.rootTrifleCertEncoded!).getCertificate() + let deviceCertificate = try TrifleCertificate.deserialize(data: TestFixtures.deviceTrifleCertEncoded3!).getCertificate() + let rootCertificate = try TrifleCertificate.deserialize(data: TestFixtures.rootTrifleCertEncoded3!).getCertificate() let isVerified = try deviceCertificate.verify( certificateRequest: try ProtoDecoder().decode(MobileCertificateRequest.self, from: mobileCertReq.serialize()), diff --git a/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt b/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt index 6efd6af..35ecc79 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/Certificate.kt @@ -1,11 +1,6 @@ package app.cash.trifle -import app.cash.trifle.CertificateRequest.PKCS10Request -import app.cash.trifle.TrifleErrors.CSRMismatch -import app.cash.trifle.internal.validators.CertChainValidatorFactory import okio.ByteString.Companion.toByteString -import org.bouncycastle.cert.X509CertificateHolder -import java.util.Date import app.cash.trifle.protos.api.alpha.Certificate as CertificateProto /** @@ -27,51 +22,6 @@ data class Certificate internal constructor( version = CERTIFICATE_VERSION ).encode() - /** - * Verify that the provided certificate matches what we expected. - * It matches the CSR that we have and the root cert is what - * we expect. - * - * @param certificateRequest - request used to generate this certificate - * @param ancestorCertificateChain - list of certificates preceding *this* one. Namely, the first - * entry should be the certificate corresponding to the issuer of this certificate, and each - * thereafter should be the issuer of the one before it. - * @param anchorCertificate - the trust anchor against which we would like to verify the - * ancestorCertificateChain. This may be the terminal (root) certificate of the chain or may be - * an intermediate certificate in the chain which is already trusted. - * @param date - The date to use for verification against certificates' validity windows. If null, - * the current time is used. - * - * @return - [Result] indicating [Result.isSuccess] or [Result.isFailure]: - * - success value is expressed as a [Unit] (Nothing) - * - failure value is expressed as a [TrifleErrors] - */ - fun verify( - certificateRequest: CertificateRequest, - ancestorCertificateChain: List, - anchorCertificate: Certificate, - date: Date? = null - ): Result { - // First check to see if the certificate chain validates - val certChainResult = CertChainValidatorFactory.get(anchorCertificate, 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 - } - } - } - } - } - override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/jvm/src/main/kotlin/app/cash/trifle/SignedData.kt b/jvm/src/main/kotlin/app/cash/trifle/SignedData.kt index 6efd413..30d38ff 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/SignedData.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/SignedData.kt @@ -3,7 +3,7 @@ package app.cash.trifle import app.cash.trifle.TrifleErrors.InvalidSignature import app.cash.trifle.internal.TrifleAlgorithmIdentifier import app.cash.trifle.internal.providers.JCAContentVerifierProvider -import app.cash.trifle.internal.validators.CertChainValidatorFactory +import app.cash.trifle.validators.CertChainValidatorFactory import okio.ByteString.Companion.toByteString import app.cash.trifle.protos.api.alpha.Certificate as CertificateProto import app.cash.trifle.protos.api.alpha.SignedData as SignedDataProto 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/delegate/DelegateImpl.kt b/jvm/src/main/kotlin/app/cash/trifle/delegate/DelegateImpl.kt index 2fd58a3..808f39c 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/delegate/DelegateImpl.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/delegate/DelegateImpl.kt @@ -124,11 +124,7 @@ internal open class DelegateImpl( envelopedData = envelopedData, signature = signature, certificates = certificates - ).also { - if (it.verify(certificates.last()).isFailure) { - throw IllegalStateException("Signed data output is invalid.") - } - } + ) } private fun SubjectPublicKeyInfo.toAuthorityKeyIdentifier(): AuthorityKeyIdentifier = diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertChainValidator.kt b/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertChainValidator.kt deleted file mode 100644 index 2afa7a4..0000000 --- a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertChainValidator.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.cash.trifle.internal.validators - -import app.cash.trifle.Certificate - -sealed interface CertChainValidator { - /** - * Validates the specific list of Trifle Certificates (certificate chain) - * against the trust anchor(s). - * - * @param certChain the list of certificates. - */ - fun validate(certChain: List): Result -} 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 deleted file mode 100644 index cbc6709..0000000 --- a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/X509CertChainValidator.kt +++ /dev/null @@ -1,91 +0,0 @@ -package app.cash.trifle.internal.validators - -import app.cash.trifle.Certificate -import app.cash.trifle.TrifleErrors.ExpiredCertificate -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 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 -import java.security.cert.X509Certificate -import java.util.Date -import java.security.cert.CertPathValidator as JCACertPathValidator - -/** - * X.509 specific implementation for validating certificate chains (certificate paths) with - * a specific set of PKIX parameters. - */ -internal class X509CertChainValidator( - certAnchor: Certificate, - date: Date? = null -) : CertChainValidator { - private val pkixParams: PKIXParameters = PKIXParameters( - setOf( - TrustAnchor( - certAnchor.certificate.inputStream().use { - X509FACTORY.generateCertificate(it) - } as X509Certificate, - null - ) - ) - ) - - init { - pkixParams.isRevocationEnabled = false - pkixParams.date = date - } - - override fun validate(certChain: List): Result = - try { - val x509Certs = certChain.map { trifleCert -> - trifleCert.certificate.inputStream().use { - X509FACTORY.generateCertificate(it) as X509Certificate - } - }.maybeDropRoot() - - if (x509Certs.isEmpty()) { - Result.failure(InvalidCertPath) - } else { - PATH_VALIDATOR.validate(X509FACTORY.generateCertPath(x509Certs), pkixParams) - Result.success(Unit) - } - } catch (e: CertPathValidatorException) { - // https://docs.oracle.com/javase/8/docs/api/java/security/cert/PKIXReason.html - // https://docs.oracle.com/javase/8/docs/api/java/security/cert/CertPathValidatorException.BasicReason.html - when (e.reason) { - BasicReason.EXPIRED -> Result.failure(ExpiredCertificate) - BasicReason.INVALID_SIGNATURE -> Result.failure(InvalidSignature) - PKIXReason.NO_TRUST_ANCHOR -> Result.failure(NoTrustAnchor) - else -> Result.failure( - UnspecifiedFailure("Unspecified Trifle verification failure", e) - ) - } - } catch (e: Exception) { - Result.failure(UnspecifiedFailure("Unspecified Trifle verification failure", e)) - } - - private fun List.maybeDropRoot(): List { - // Remove root and all certs following it in the chain - val rootIndex = indexOfFirst { - it.keyUsage != null && it.keyUsage[KEY_CERT_SIGN] - } - return if (rootIndex > -1) { - dropLast(size - rootIndex) - } else { - this - } - } - - private companion object { - private const val KEY_CERT_SIGN = 5 - private val PATH_VALIDATOR = JCACertPathValidator.getInstance( - JCACertPathValidator.getDefaultType() - ) - private val X509FACTORY = CertificateFactory.getInstance("X509") - } -} diff --git a/jvm/src/main/kotlin/app/cash/trifle/validators/CertChainValidator.kt b/jvm/src/main/kotlin/app/cash/trifle/validators/CertChainValidator.kt new file mode 100644 index 0000000..e02c4db --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/validators/CertChainValidator.kt @@ -0,0 +1,96 @@ +package app.cash.trifle.validators + +import app.cash.trifle.Certificate +import app.cash.trifle.TrifleErrors +import app.cash.trifle.validators.CertificateUtil.generateCertPath +import app.cash.trifle.validators.CertificateUtil.toX509Certificate +import java.security.cert.CertPathValidator +import java.security.cert.CertPathValidatorException +import java.security.cert.PKIXParameters +import java.security.cert.PKIXReason +import java.security.cert.TrustAnchor +import java.security.cert.X509Certificate +import java.util.Date + +sealed interface CertChainValidator { + /** + * Validates the specific list of Trifle Certificates (certificate chain) + * against the trust anchor(s). + * + * @param certChain the list of certificates. + */ + fun validate(certChain: List): Result + + /** + * X.509 specific implementation for validating certificate chains (certificate paths) with + * a specific set of PKIX parameters. + */ + class X509CertChainValidator(certAnchor: Certificate, date: Date? = null) : CertChainValidator { + private val pkixParams: PKIXParameters = PKIXParameters( + setOf( + TrustAnchor( + certAnchor.toX509Certificate(), + null + ) + ) + ) + + init { + pkixParams.isRevocationEnabled = false + pkixParams.date = date + } + + override fun validate(certChain: List): Result = + try { + val x509Certs = certChain.map { it.toX509Certificate() }.maybeDropRoot() + + if (x509Certs.isEmpty()) { + Result.failure(TrifleErrors.InvalidCertPath) + } else { + PATH_VALIDATOR.validate(x509Certs.generateCertPath(), pkixParams) + Result.success(Unit) + } + } catch (e: CertPathValidatorException) { + // https://docs.oracle.com/javase/8/docs/api/java/security/cert/PKIXReason.html + // https://docs.oracle.com/javase/8/docs/api/java/security/cert/CertPathValidatorException.BasicReason.html + when (e.reason) { + CertPathValidatorException.BasicReason.EXPIRED -> Result.failure( + TrifleErrors.ExpiredCertificate + ) + CertPathValidatorException.BasicReason.INVALID_SIGNATURE -> Result.failure( + TrifleErrors.InvalidSignature + ) + PKIXReason.NO_TRUST_ANCHOR -> Result.failure(TrifleErrors.NoTrustAnchor) + else -> Result.failure( + TrifleErrors.UnspecifiedFailure("Unspecified Trifle verification failure", e) + ) + } + } catch (e: Exception) { + Result.failure( + TrifleErrors.UnspecifiedFailure( + "Unspecified Trifle verification failure", + e + ) + ) + } + + private fun List.maybeDropRoot(): List { + // Remove root and all certs following it in the chain + val rootIndex = indexOfFirst { + it.keyUsage != null && it.keyUsage[KEY_CERT_SIGN] + } + return if (rootIndex > -1) { + dropLast(size - rootIndex) + } else { + this + } + } + + private companion object { + private const val KEY_CERT_SIGN = 5 + private val PATH_VALIDATOR = CertPathValidator.getInstance( + CertPathValidator.getDefaultType() + ) + } + } +} diff --git a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertChainValidatorFactory.kt b/jvm/src/main/kotlin/app/cash/trifle/validators/CertChainValidatorFactory.kt similarity index 89% rename from jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertChainValidatorFactory.kt rename to jvm/src/main/kotlin/app/cash/trifle/validators/CertChainValidatorFactory.kt index 5f2e581..e291b7d 100644 --- a/jvm/src/main/kotlin/app/cash/trifle/internal/validators/CertChainValidatorFactory.kt +++ b/jvm/src/main/kotlin/app/cash/trifle/validators/CertChainValidatorFactory.kt @@ -1,7 +1,8 @@ -package app.cash.trifle.internal.validators +package app.cash.trifle.validators import app.cash.trifle.Certificate import app.cash.trifle.Certificate.Companion.CERTIFICATE_VERSION +import app.cash.trifle.validators.CertChainValidator.X509CertChainValidator import java.util.Date /** diff --git a/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateUtil.kt b/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateUtil.kt new file mode 100644 index 0000000..122cf80 --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateUtil.kt @@ -0,0 +1,17 @@ +package app.cash.trifle.validators + +import app.cash.trifle.Certificate +import java.security.cert.CertPath +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +internal object CertificateUtil { + 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") +} diff --git a/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateValidator.kt b/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateValidator.kt new file mode 100644 index 0000000..171bd41 --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateValidator.kt @@ -0,0 +1,63 @@ +package app.cash.trifle.validators + +import app.cash.trifle.Certificate +import app.cash.trifle.CertificateRequest +import app.cash.trifle.TrifleErrors +import app.cash.trifle.validators.CertificateUtil.toX509Certificate +import org.bouncycastle.cert.X509CertificateHolder +import java.security.cert.CertificateExpiredException +import java.security.cert.CertificateNotYetValidException +import java.util.Date + +sealed interface CertificateValidator { + /** + * Validates if the provided Trifle Certificate is within the validity window of the date. + * + * @param date - The date to use for verification against certificates' validity windows. If null, + * the current time is used. + */ + fun validate(date: Date?): Result + + /** + * Validates if the provided Trifle Certificate matches the CSR that we have. + * + * @param certificateRequest - request used to generate this certificate. + * + * @return - [Result] indicating [Result.isSuccess] or [Result.isFailure]: + * - success value is expressed as a [Unit] (Nothing) + * - failure value is expressed as a [TrifleErrors.CSRMismatch] if attributes are mismatched + */ + fun validate(certificateRequest: CertificateRequest): Result + + /** + * X.509 specific implementation for validating a Trifle Certificate. + */ + class X509CertificateValidator(certificate: Certificate) : CertificateValidator { + private val x509Certificate = certificate.toX509Certificate() + private val x509CertHolder = X509CertificateHolder(certificate.certificate) + override fun validate(date: Date?): Result = + try { + x509Certificate.checkValidity(date ?: Date()) + Result.success(Unit) + } catch (e: CertificateExpiredException) { + Result.failure(TrifleErrors.ExpiredCertificate) + } catch (e: CertificateNotYetValidException) { + Result.failure(TrifleErrors.NotValidYetCertificate) + } + + override fun validate(certificateRequest: CertificateRequest): Result { + when (certificateRequest) { + is CertificateRequest.PKCS10Request -> { + // Certificate chain matches, check with certificate request. + // TODO(dcashman): Check other attributes as well. + if (certificateRequest.pkcs10Req.subject != x509CertHolder.subject || + certificateRequest.pkcs10Req.subjectPublicKeyInfo != x509CertHolder.subjectPublicKeyInfo + ) { + return Result.failure(TrifleErrors.CSRMismatch) + } + } + } + return Result.success(Unit) + } + } +} diff --git a/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateValidatorFactory.kt b/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateValidatorFactory.kt new file mode 100644 index 0000000..f83f9c0 --- /dev/null +++ b/jvm/src/main/kotlin/app/cash/trifle/validators/CertificateValidatorFactory.kt @@ -0,0 +1,18 @@ +package app.cash.trifle.validators + +import app.cash.trifle.Certificate +import app.cash.trifle.validators.CertificateValidator.X509CertificateValidator + +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(certificate) + else -> throw UnsupportedOperationException("Unsupported version of Trifle Certificate") + } + } +} diff --git a/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt b/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt deleted file mode 100644 index d625dab..0000000 --- a/jvm/src/test/kotlin/app/cash/trifle/CertificateTests.kt +++ /dev/null @@ -1,67 +0,0 @@ -package app.cash.trifle - -import app.cash.trifle.TrifleErrors.* -import app.cash.trifle.testing.TestCertificateAuthority -import org.junit.jupiter.api.* -import org.junit.jupiter.api.Assertions.* -import java.security.* -import java.time.Duration -import java.time.Instant -import java.util.Date - -internal class CertificateTests { - @Nested - @DisplayName("Certificate#verify() Tests") - inner class CertificateVerifyTests { - @Test - fun `test verify() succeeds for a properly issued certificate`() { - val result = endEntity.certificate.verify( - endEntity.certRequest, - endEntity.certChain.drop(1), - certificateAuthority.rootCertificate - ) - 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( - otherEndEntity.certRequest, - endEntity.certChain.drop(1), - certificateAuthority.rootCertificate - ) - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is CSRMismatch) - } - - @Test - fun `test verify() fails with NoTrustAnchor for a different root certificate`() { - val result = endEntity.certificate.verify( - endEntity.certRequest, - otherEndEntity.certChain.drop(1), - otherCertificateAuthority.rootCertificate - ) - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is NoTrustAnchor) - } - - @Test - fun `test verify() fails with ExpiredCertificate for an expired certificate`() { - val result = endEntity.certificate.verify( - endEntity.certRequest, - endEntity.certChain.drop(1), - certificateAuthority.rootCertificate, - Date.from(Instant.now().plus(Duration.ofDays(365))) - ) - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is ExpiredCertificate) - } - } - - companion object { - private val certificateAuthority = TestCertificateAuthority("issuingEntity") - private val otherCertificateAuthority = TestCertificateAuthority("otherIssuingEntity") - private val endEntity = certificateAuthority.createTestEndEntity("entity") - private val otherEndEntity = otherCertificateAuthority.createTestEndEntity("otherEntity") - } -} diff --git a/jvm/src/test/kotlin/app/cash/trifle/TrifleTest.kt b/jvm/src/test/kotlin/app/cash/trifle/TrifleTest.kt index 9337292..aa35920 100644 --- a/jvm/src/test/kotlin/app/cash/trifle/TrifleTest.kt +++ b/jvm/src/test/kotlin/app/cash/trifle/TrifleTest.kt @@ -170,13 +170,6 @@ internal class TrifleTest { fun `test createSignedData succeeds`() { assertEquals(endEntity.createSignedData(rawData).envelopedData.data, rawData) } - - @Test - fun `test createSignedData fails`() { - assertThrows { - endEntity.createSignedData(rawData, certificateAuthority.createTestEndEntity().certChain) - } - } } @Nested diff --git a/jvm/src/test/kotlin/app/cash/trifle/internal/validators/CertChainValidatorFactoryTests.kt b/jvm/src/test/kotlin/app/cash/trifle/validators/CertChainValidatorFactoryTests.kt similarity index 89% rename from jvm/src/test/kotlin/app/cash/trifle/internal/validators/CertChainValidatorFactoryTests.kt rename to jvm/src/test/kotlin/app/cash/trifle/validators/CertChainValidatorFactoryTests.kt index 0081b3d..a20a261 100644 --- a/jvm/src/test/kotlin/app/cash/trifle/internal/validators/CertChainValidatorFactoryTests.kt +++ b/jvm/src/test/kotlin/app/cash/trifle/validators/CertChainValidatorFactoryTests.kt @@ -1,6 +1,7 @@ -package app.cash.trifle.internal.validators +package app.cash.trifle.validators import app.cash.trifle.testing.TestCertificateAuthority +import app.cash.trifle.validators.CertChainValidator.X509CertChainValidator import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test diff --git a/jvm/src/test/kotlin/app/cash/trifle/internal/validators/CertChainValidatorTests.kt b/jvm/src/test/kotlin/app/cash/trifle/validators/CertChainValidatorTests.kt similarity index 93% rename from jvm/src/test/kotlin/app/cash/trifle/internal/validators/CertChainValidatorTests.kt rename to jvm/src/test/kotlin/app/cash/trifle/validators/CertChainValidatorTests.kt index 415154b..1bc4b5d 100644 --- a/jvm/src/test/kotlin/app/cash/trifle/internal/validators/CertChainValidatorTests.kt +++ b/jvm/src/test/kotlin/app/cash/trifle/validators/CertChainValidatorTests.kt @@ -1,8 +1,9 @@ -package app.cash.trifle.internal.validators +package app.cash.trifle.validators import app.cash.trifle.TrifleErrors.InvalidCertPath import app.cash.trifle.TrifleErrors.NoTrustAnchor import app.cash.trifle.testing.TestCertificateAuthority +import app.cash.trifle.validators.CertChainValidator.X509CertChainValidator import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested diff --git a/jvm/src/test/kotlin/app/cash/trifle/validators/CertificateValidatorFactoryTests.kt b/jvm/src/test/kotlin/app/cash/trifle/validators/CertificateValidatorFactoryTests.kt new file mode 100644 index 0000000..d58186d --- /dev/null +++ b/jvm/src/test/kotlin/app/cash/trifle/validators/CertificateValidatorFactoryTests.kt @@ -0,0 +1,31 @@ +package app.cash.trifle.validators + +import app.cash.trifle.testing.TestCertificateAuthority +import app.cash.trifle.validators.CertificateValidator.X509CertificateValidator +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows + +@DisplayName("Certificate Validator Factory Tests") +class CertificateValidatorFactoryTests { + @Test + fun `test get() returns X509 Certificate Validator for version 0`() { + val validator = assertDoesNotThrow { + CertificateValidatorFactory.get(certAnchor) + } + Assertions.assertTrue(validator is X509CertificateValidator) + } + + @Test + fun `test get() throws for unsupported version`() { + assertThrows( + "Unsupported version of Trifle Certificate" + ) { CertificateValidatorFactory.get(certAnchor.copy(version = 1)) } + } + + private companion object { + private val certAnchor = TestCertificateAuthority().rootCertificate + } +} \ No newline at end of file diff --git a/jvm/src/test/kotlin/app/cash/trifle/validators/CertificateValidatorTests.kt b/jvm/src/test/kotlin/app/cash/trifle/validators/CertificateValidatorTests.kt new file mode 100644 index 0000000..4e341e4 --- /dev/null +++ b/jvm/src/test/kotlin/app/cash/trifle/validators/CertificateValidatorTests.kt @@ -0,0 +1,58 @@ +package app.cash.trifle.validators + +import app.cash.trifle.TrifleErrors.CSRMismatch +import app.cash.trifle.TrifleErrors.ExpiredCertificate +import app.cash.trifle.TrifleErrors.NotValidYetCertificate +import app.cash.trifle.testing.TestCertificateAuthority +import app.cash.trifle.validators.CertificateValidator.X509CertificateValidator +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.Duration +import java.time.Instant +import java.util.Date + +internal class CertificateValidatorTests { + @Nested + @DisplayName("X509 Certificate Validator Tests") + inner class X509CertChainValidatorTests { + @Test + fun `test validate() succeeds for valid certificate`() { + Assertions.assertTrue(validator.validate(null).isSuccess) + } + + @Test + fun `test validate() succeeds for matching attributes from certificate request`() { + Assertions.assertTrue(validator.validate(endEntity.certRequest).isSuccess) + } + + @Test + fun `test validate() fails with ExpiredCertificate`() { + val result = validator.validate(Date.from(Instant.now().plus(Duration.ofDays(365)))) + Assertions.assertTrue(result.isFailure) + Assertions.assertTrue(result.exceptionOrNull() is ExpiredCertificate) + } + + @Test + fun `test validate() fails with NotValidYetCertificate`() { + val result = validator.validate(Date.from(Instant.now().minus(Duration.ofDays(1)))) + Assertions.assertTrue(result.isFailure) + Assertions.assertTrue(result.exceptionOrNull() is NotValidYetCertificate) + } + + @Test + fun `test validate() fails with mismatched attributes from certificate request`() { + val result = validator.validate(otherEndEntity.certRequest) + Assertions.assertTrue(result.isFailure) + Assertions.assertTrue(result.exceptionOrNull() is CSRMismatch) + } + } + + companion object { + private val certificateAuthority = TestCertificateAuthority() + private val endEntity = certificateAuthority.createTestEndEntity() + private val otherEndEntity = certificateAuthority.createTestEndEntity() + private val validator = X509CertificateValidator(endEntity.certificate) + } +} \ No newline at end of file