diff --git a/build-logic/src/main/kotlin/ckbuild/tests/GenerateProviderTestsTask.kt b/build-logic/src/main/kotlin/ckbuild/tests/GenerateProviderTestsTask.kt index d3a10432..1b44fe1a 100644 --- a/build-logic/src/main/kotlin/ckbuild/tests/GenerateProviderTestsTask.kt +++ b/build-logic/src/main/kotlin/ckbuild/tests/GenerateProviderTestsTask.kt @@ -16,6 +16,9 @@ abstract class GenerateProviderTestsTask : DefaultTask() { @get:Input abstract val imports: ListProperty + @get:Input + abstract val testClasses: ListProperty + @get:Input abstract val providerInitializers: MapProperty @@ -29,11 +32,13 @@ abstract class GenerateProviderTestsTask : DefaultTask() { check(outputDirectory.deleteRecursively()) { "Failed to cleanup files" } check(outputDirectory.mkdirs()) { "Failed to create directories" } + val classes = testClasses.get() providerInitializers.get().forEach { (classifier, providerInitialization) -> outputDirectory.resolve("${classifier}_tests.kt").writeText( testsFileContent( packageName = packageName.get(), imports = imports.get(), + testClasses = classes, providerClassifier = classifier, providerInitialization = providerInitialization ) @@ -44,6 +49,7 @@ abstract class GenerateProviderTestsTask : DefaultTask() { private fun testsFileContent( packageName: String, imports: List, + testClasses: List, providerClassifier: String, providerInitialization: String, ): String = buildString { @@ -110,6 +116,12 @@ abstract class GenerateProviderTestsTask : DefaultTask() { "EcdsaCompatibilityTest", "EcdhCompatibilityTest", + // Edwards-family + "EdDsaTest", + "EdDsaCompatibilityTest", + "XdhTest", + "XdhCompatibilityTest", + "RsaOaepTest", "RsaOaepCompatibilityTest", "RsaPkcs1Test", diff --git a/cryptography-providers/tests/src/commonMain/kotlin/compatibility/EdDsaCompatibilityTest.kt b/cryptography-providers/tests/src/commonMain/kotlin/compatibility/EdDsaCompatibilityTest.kt new file mode 100644 index 00000000..1b2e02ad --- /dev/null +++ b/cryptography-providers/tests/src/commonMain/kotlin/compatibility/EdDsaCompatibilityTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.providers.tests.compatibility + +import dev.whyoleg.cryptography.* +import dev.whyoleg.cryptography.algorithms.* +import dev.whyoleg.cryptography.providers.tests.* +import dev.whyoleg.cryptography.providers.tests.compatibility.api.* +import dev.whyoleg.cryptography.random.* +import dev.whyoleg.cryptography.serialization.pem.* +import kotlinx.io.bytestring.* +import kotlinx.serialization.* +import kotlin.test.* + +private val edPublicKeyFormats = listOf( + EdDSA.PublicKey.Format.JWK, + EdDSA.PublicKey.Format.RAW, + EdDSA.PublicKey.Format.DER, + EdDSA.PublicKey.Format.PEM, +).associateBy { it.name } + +private val edPrivateKeyFormats = listOf( + EdDSA.PrivateKey.Format.JWK, + EdDSA.PrivateKey.Format.RAW, + EdDSA.PrivateKey.Format.DER, + EdDSA.PrivateKey.Format.PEM, +).associateBy { it.name } + +abstract class EdDsaCompatibilityTest( + provider: CryptographyProvider, +) : CompatibilityTest(EdDSA, provider) { + + @Serializable + private data class KeyParameters(val curveName: String) : TestParameters { + val curve: EdDSA.Curve + get() = when (curveName) { + EdDSA.Curve.Ed25519.name -> EdDSA.Curve.Ed25519 + EdDSA.Curve.Ed448.name -> EdDSA.Curve.Ed448 + else -> error("Unsupported curve: $curveName") + } + } + + override suspend fun CompatibilityTestScope.generate(isStressTest: Boolean) { + val signatureIterations = if (isStressTest) 5 else 2 + + listOf(EdDSA.Curve.Ed25519, EdDSA.Curve.Ed448).forEach { curve -> + if (!supportsAlgorithmOnCurve(curve)) return@forEach + + val keyParametersId = api.keyPairs.saveParameters(KeyParameters(curve.name)) + + val keyIterations = if (isStressTest) 5 else 2 + algorithm.keyPairGenerator(curve).generateKeys(keyIterations) { keyPair -> + val keyReference = api.keyPairs.saveData( + keyParametersId, + KeyPairData( + public = KeyData(keyPair.publicKey.encodeTo(edPublicKeyFormats.values, ::supportsKeyFormat)), + private = KeyData(keyPair.privateKey.encodeTo(edPrivateKeyFormats.values, ::supportsKeyFormat)), + ) + ) + + repeat(signatureIterations) { + val dataSize = CryptographyRandom.nextInt(0, 8192) + val data = ByteString(CryptographyRandom.nextBytes(dataSize)) + val signature = keyPair.privateKey.signatureGenerator().generateSignature(data) + + api.signatures.saveData( + parametersId = api.signatures.saveParameters(TestParameters.Empty), + data = SignatureData(keyReference, data, signature) + ) + } + } + } + } + + private fun ProviderTestScope.supportsAlgorithmOnCurve(curve: EdDSA.Curve): Boolean { + return when { + // CryptoKit supports only Ed25519 + context.provider.isCryptoKit && curve == EdDSA.Curve.Ed448 -> { + logger.print("SKIP: CryptoKit supports Ed25519 only") + false + } + // WebCrypto currently supports Ed25519 but not Ed448 + context.provider.isWebCrypto && curve == EdDSA.Curve.Ed448 -> { + logger.print("SKIP: 'Ed448' is not supported") + false + } + else -> true + } + } + + override suspend fun CompatibilityTestScope.validate() { + // Decode saved keys + val keyPairs = buildMap { + api.keyPairs.getParameters { params, parametersId, _ -> + val publicKeyDecoder = algorithm.publicKeyDecoder(params.curve) + val privateKeyDecoder = algorithm.privateKeyDecoder(params.curve) + api.keyPairs.getData(parametersId) { (public, private), keyReference, _ -> + val publicKeys = publicKeyDecoder.decodeFrom( + formats = public.formats, + formatOf = edPublicKeyFormats::getValue, + supports = ::supportsKeyFormat + ) { key, format, bytes -> + when (format) { + EdDSA.PublicKey.Format.JWK -> {} + EdDSA.PublicKey.Format.PEM -> { + val expected = PemDocument.decode(bytes) + val actual = PemDocument.decode(key.encodeToByteString(format)) + assertEquals(expected.label, actual.label) + assertEquals(PemLabel.PublicKey, actual.label) + assertContentEquals(expected.content, actual.content, "Public Key $format content encoding") + } + else -> assertContentEquals(bytes, key.encodeToByteString(format), "Public Key $format encoding") + } + } + val privateKeys = privateKeyDecoder.decodeFrom( + formats = private.formats, + formatOf = edPrivateKeyFormats::getValue, + supports = ::supportsKeyFormat + ) { key, format, bytes -> + when (format) { + EdDSA.PrivateKey.Format.JWK -> {} + EdDSA.PrivateKey.Format.PEM -> { + val expected = PemDocument.decode(bytes) + val actual = PemDocument.decode(key.encodeToByteString(format)) + assertEquals(expected.label, actual.label) + assertEquals(PemLabel.PrivateKey, actual.label) + assertContentEquals(expected.content, actual.content, "Private Key $format content encoding") + } + else -> assertContentEquals(bytes, key.encodeToByteString(format), "Private Key $format encoding") + } + } + put(keyReference, publicKeys to privateKeys) + } + } + } + + // Validate signatures across providers + api.signatures.getParameters { _, parametersId, _ -> + api.signatures.getData(parametersId) { (keyReference, data, signature), _, _ -> + val (publicKeys, privateKeys) = keyPairs[keyReference] ?: return@getData + val verifiers = publicKeys.map { it.signatureVerifier() } + val generators = privateKeys.map { it.signatureGenerator() } + + verifiers.forEach { verifier -> + assertTrue(verifier.tryVerifySignature(data, signature), "Verify") + generators.forEach { generator -> + val s = generator.generateSignature(data) + assertTrue(verifier.tryVerifySignature(data, s), "Sign-Verify") + } + } + } + } + } +} diff --git a/cryptography-providers/tests/src/commonMain/kotlin/compatibility/XdhCompatibilityTest.kt b/cryptography-providers/tests/src/commonMain/kotlin/compatibility/XdhCompatibilityTest.kt new file mode 100644 index 00000000..bb9e962a --- /dev/null +++ b/cryptography-providers/tests/src/commonMain/kotlin/compatibility/XdhCompatibilityTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.providers.tests.compatibility + +import dev.whyoleg.cryptography.* +import dev.whyoleg.cryptography.algorithms.* +import dev.whyoleg.cryptography.providers.tests.* +import dev.whyoleg.cryptography.providers.tests.compatibility.api.* +import dev.whyoleg.cryptography.serialization.pem.* +import kotlinx.serialization.* +import kotlin.test.* + +private val xdhPublicKeyFormats = listOf( + XDH.PublicKey.Format.JWK, + XDH.PublicKey.Format.RAW, + XDH.PublicKey.Format.DER, + XDH.PublicKey.Format.PEM, +).associateBy { it.name } + +private val xdhPrivateKeyFormats = listOf( + XDH.PrivateKey.Format.JWK, + XDH.PrivateKey.Format.RAW, + XDH.PrivateKey.Format.DER, + XDH.PrivateKey.Format.PEM, +).associateBy { it.name } + +abstract class XdhCompatibilityTest( + provider: CryptographyProvider, +) : CompatibilityTest(XDH, provider) { + + @Serializable + private data class KeyParameters(val curveName: String) : TestParameters { + val curve: XDH.Curve + get() = when (curveName) { + XDH.Curve.X25519.name -> XDH.Curve.X25519 + XDH.Curve.X448.name -> XDH.Curve.X448 + else -> error("Unsupported curve: $curveName") + } + } + + override suspend fun CompatibilityTestScope.generate(isStressTest: Boolean) { + val parametersId = api.sharedSecrets.saveParameters(TestParameters.Empty) + + listOf(XDH.Curve.X25519, XDH.Curve.X448).forEach { curve -> + // CryptoKit supports only X25519 + if (context.provider.isCryptoKit && curve == XDH.Curve.X448) return@forEach + // WebCrypto currently supports X25519 but not X448 + if (context.provider.isWebCrypto && curve == XDH.Curve.X448) { + logger.print("SKIP: 'X448' is not supported") + return@forEach + } + val keyParametersId = api.keyPairs.saveParameters(KeyParameters(curve.name)) + + val keyIterations = if (isStressTest) 5 else 2 + // Generate two key pairs for shared secret validation + algorithm.keyPairGenerator(curve).generateKeys(keyIterations) { keyPair -> + val keyReference = api.keyPairs.saveData( + keyParametersId, + KeyPairData( + public = KeyData(keyPair.publicKey.encodeTo(xdhPublicKeyFormats.values, ::supportsKeyFormat)), + private = KeyData(keyPair.privateKey.encodeTo(xdhPrivateKeyFormats.values, ::supportsKeyFormat)) + ) + ) + + algorithm.keyPairGenerator(curve).generateKeys(1) { otherKeyPair -> + val otherKeyReference = api.keyPairs.saveData( + keyParametersId, + KeyPairData( + public = KeyData(otherKeyPair.publicKey.encodeTo(xdhPublicKeyFormats.values, ::supportsKeyFormat)), + private = KeyData(otherKeyPair.privateKey.encodeTo(xdhPrivateKeyFormats.values, ::supportsKeyFormat)) + ) + ) + + val shared = keyPair.privateKey.sharedSecretGenerator().generateSharedSecret(otherKeyPair.publicKey) + api.sharedSecrets.saveData(parametersId, SharedSecretData(keyReference, otherKeyReference, shared)) + } + } + } + } + + override suspend fun CompatibilityTestScope.validate() { + val keyPairs = buildMap { + api.keyPairs.getParameters { params, parametersId, _ -> + val publicKeyDecoder = algorithm.publicKeyDecoder(params.curve) + val privateKeyDecoder = algorithm.privateKeyDecoder(params.curve) + api.keyPairs.getData(parametersId) { (public, private), keyReference, _ -> + val publicKeys = publicKeyDecoder.decodeFrom( + formats = public.formats, + formatOf = xdhPublicKeyFormats::getValue, + supports = ::supportsKeyFormat + ) { key, format, bytes -> + when (format) { + XDH.PublicKey.Format.PEM -> { + val expected = PemDocument.decode(bytes) + val actual = PemDocument.decode(key.encodeToByteString(format)) + assertEquals(expected.label, actual.label) + assertEquals(PemLabel.PublicKey, actual.label) + assertContentEquals(expected.content, actual.content, "Public Key $format content encoding") + } + else -> assertContentEquals(bytes, key.encodeToByteString(format), "Public Key $format encoding") + } + } + val privateKeys = privateKeyDecoder.decodeFrom( + formats = private.formats, + formatOf = xdhPrivateKeyFormats::getValue, + supports = ::supportsKeyFormat + ) { key, format, bytes -> + when (format) { + XDH.PrivateKey.Format.PEM -> { + val expected = PemDocument.decode(bytes) + val actual = PemDocument.decode(key.encodeToByteString(format)) + assertEquals(expected.label, actual.label) + assertEquals(PemLabel.PrivateKey, actual.label) + assertContentEquals(expected.content, actual.content, "Private Key $format content encoding") + } + else -> assertContentEquals(bytes, key.encodeToByteString(format), "Private Key $format encoding") + } + } + put(keyReference, publicKeys to privateKeys) + } + } + } + + api.sharedSecrets.getParameters { _, parametersId, _ -> + api.sharedSecrets.getData(parametersId) { (keyReference, otherKeyReference, sharedSecret), _, _ -> + val (publicKeys, privateKeys) = keyPairs[keyReference] ?: return@getData + val (otherPublicKeys, otherPrivateKeys) = keyPairs[otherKeyReference] ?: return@getData + + // Verify both combinations generate the same secret + publicKeys.forEach { publicKey -> + otherPrivateKeys.forEach { otherPrivateKey -> + assertContentEquals(sharedSecret, publicKey.sharedSecretGenerator().generateSharedSecret(otherPrivateKey)) + assertContentEquals(sharedSecret, otherPrivateKey.sharedSecretGenerator().generateSharedSecret(publicKey)) + } + } + privateKeys.forEach { privateKey -> + otherPublicKeys.forEach { otherPublicKey -> + assertContentEquals(sharedSecret, privateKey.sharedSecretGenerator().generateSharedSecret(otherPublicKey)) + assertContentEquals(sharedSecret, otherPublicKey.sharedSecretGenerator().generateSharedSecret(privateKey)) + } + } + } + } + } +} diff --git a/cryptography-providers/tests/src/commonMain/kotlin/default/EdDsaTest.kt b/cryptography-providers/tests/src/commonMain/kotlin/default/EdDsaTest.kt new file mode 100644 index 00000000..de06a61a --- /dev/null +++ b/cryptography-providers/tests/src/commonMain/kotlin/default/EdDsaTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.providers.tests.default + +import dev.whyoleg.cryptography.* +import dev.whyoleg.cryptography.algorithms.* +import dev.whyoleg.cryptography.providers.tests.* +import dev.whyoleg.cryptography.random.* +import kotlinx.io.bytestring.* +import kotlin.test.* + +abstract class EdDsaTest(provider: CryptographyProvider) : AlgorithmTest(EdDSA, provider), SignatureTest { + + @Test + fun testSignVerify() = testWithAlgorithm { + val curves = listOf(EdDSA.Curve.Ed25519, EdDSA.Curve.Ed448).filter { curve -> + // CryptoKit supports only Ed25519 + !(context.provider.isCryptoKit && curve == EdDSA.Curve.Ed448) + }.ifEmpty { listOf(EdDSA.Curve.Ed25519) } + var anyRan = false + curves.forEach { curve -> + val keyPair = try { + algorithm.keyPairGenerator(curve).generateKey() + } catch (t: Throwable) { + if (context.provider.isWebCrypto) { + logger.print("SKIP: '${curve.name}' is not supported") + return@forEach + } else throw t + } + anyRan = true + + val dataSets = listOf( + ByteArray(0), + CryptographyRandom.nextBytes(32), + CryptographyRandom.nextBytes(1024) + ) + val signer = keyPair.privateKey.signatureGenerator() + val verifier = keyPair.publicKey.signatureVerifier() + dataSets.forEach { data -> + val signature = signer.generateSignature(data) + assertTrue(verifier.tryVerifySignature(data, signature)) + } + } + if (!anyRan && context.provider.isWebCrypto) return@testWithAlgorithm + } + + @Test + fun testFunctions() = testWithAlgorithm { + if (!supportsFunctions()) return@testWithAlgorithm + + val curves = listOf(EdDSA.Curve.Ed25519, EdDSA.Curve.Ed448).filter { curve -> + !(context.provider.isCryptoKit && curve == EdDSA.Curve.Ed448) + }.ifEmpty { listOf(EdDSA.Curve.Ed25519) } + curves.forEach { curve -> + val keyPair = algorithm.keyPairGenerator(curve).generateKey() + repeat(5) { + val size = CryptographyRandom.nextInt(256, 4096) + val data = ByteString(CryptographyRandom.nextBytes(size)) + assertSignaturesViaFunction(keyPair.privateKey.signatureGenerator(), keyPair.publicKey.signatureVerifier(), data) + } + } + } +} diff --git a/cryptography-providers/tests/src/commonMain/kotlin/default/SupportedAlgorithmsTest.kt b/cryptography-providers/tests/src/commonMain/kotlin/default/SupportedAlgorithmsTest.kt index 2922e417..e364dc2d 100644 --- a/cryptography-providers/tests/src/commonMain/kotlin/default/SupportedAlgorithmsTest.kt +++ b/cryptography-providers/tests/src/commonMain/kotlin/default/SupportedAlgorithmsTest.kt @@ -51,6 +51,10 @@ abstract class SupportedAlgorithmsTest(provider: CryptographyProvider) : Provide assertSupports(ECDSA) assertSupports(ECDH, !context.provider.isApple) + // Edwards-family + assertSupports(EdDSA, !context.provider.isApple) + assertSupports(XDH, !context.provider.isApple) + assertSupports(RSA.PSS, !context.provider.isCryptoKit) assertSupports(RSA.OAEP, !context.provider.isCryptoKit) assertSupports(RSA.PKCS1, !context.provider.isCryptoKit) diff --git a/cryptography-providers/tests/src/commonMain/kotlin/default/XdhTest.kt b/cryptography-providers/tests/src/commonMain/kotlin/default/XdhTest.kt new file mode 100644 index 00000000..1b8b024a --- /dev/null +++ b/cryptography-providers/tests/src/commonMain/kotlin/default/XdhTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Oleg Yukhnevich. Use of this source code is governed by the Apache 2.0 license. + */ + +package dev.whyoleg.cryptography.providers.tests.default + +import dev.whyoleg.cryptography.* +import dev.whyoleg.cryptography.algorithms.* +import dev.whyoleg.cryptography.providers.tests.* +import kotlin.test.* + +abstract class XdhTest(provider: CryptographyProvider) : AlgorithmTest(XDH, provider) { + + @Test + fun testDeriveSharedSecret() = testWithAlgorithm { + val curves = listOf(XDH.Curve.X25519, XDH.Curve.X448).filter { curve -> + // CryptoKit supports only X25519 + !(context.provider.isCryptoKit && curve == XDH.Curve.X448) + }.ifEmpty { listOf(XDH.Curve.X25519) } + var anyRan = false + curves.forEach { curve -> + val a = try { + algorithm.keyPairGenerator(curve).generateKey() + } catch (t: Throwable) { + if (context.provider.isWebCrypto) { + logger.print("SKIP: '${curve.name}' is not supported") + return@forEach + } else throw t + } + val b = algorithm.keyPairGenerator(curve).generateKey() + anyRan = true + + val aSecret = a.privateKey.sharedSecretGenerator().generateSharedSecretToByteArray(b.publicKey) + val bSecret = b.privateKey.sharedSecretGenerator().generateSharedSecretToByteArray(a.publicKey) + assertContentEquals(aSecret, bSecret) + val expectedSize = when (curve) { + XDH.Curve.X25519 -> 32 + XDH.Curve.X448 -> 56 + } + assertEquals(expectedSize, aSecret.size) + } + if (!anyRan && context.provider.isWebCrypto) return@testWithAlgorithm + } +} diff --git a/cryptography-providers/tests/src/commonMain/kotlin/support.kt b/cryptography-providers/tests/src/commonMain/kotlin/support.kt index f4e61b58..479443a5 100644 --- a/cryptography-providers/tests/src/commonMain/kotlin/support.kt +++ b/cryptography-providers/tests/src/commonMain/kotlin/support.kt @@ -41,6 +41,11 @@ fun AlgorithmTestScope<*>.supportsKeyFormat(format: KeyFormat): Boolean = suppor // only WebCrypto supports JWK for now format.name == "JWK" && !provider.isWebCrypto -> "JWK key format" + // WebCrypto cannot export/import private keys in 'raw' format; requires PKCS#8 + (provider.isWebCrypto && ( + format == EdDSA.PrivateKey.Format.RAW || + format == XDH.PrivateKey.Format.RAW + )) -> "RAW private key format" format == EC.PublicKey.Format.RAW.Compressed && provider.isApple -> "compressed key format" else -> null @@ -96,9 +101,14 @@ fun AlgorithmTestScope.supportsEncryption(): Boolean = supports { fun AlgorithmTestScope>.supportsCurve(curve: EC.Curve): Boolean = supports { when { // JDK default, WebCrypto and Apple don't support secp256k1 or brainpool - curve in listOf(EC.Curve.secp256k1, EC.Curve.brainpoolP256r1, EC.Curve.brainpoolP384r1, EC.Curve.brainpoolP512r1) && ( - provider.isJdkDefault || provider.isWebCrypto || provider.isApple || provider.isCryptoKit - ) -> "ECDSA ${curve.name}" + curve in listOf( + EC.Curve.secp256k1, + EC.Curve.brainpoolP256r1, + EC.Curve.brainpoolP384r1, + EC.Curve.brainpoolP512r1, + ) && ( + provider.isJdkDefault || provider.isWebCrypto || provider.isApple || provider.isCryptoKit + ) -> "ECDSA ${curve.name}" else -> null } @@ -141,6 +151,15 @@ fun ProviderTestScope.supports(algorithmId: CryptographyAlgorithmId<*>): Boolean when (algorithmId) { AES.CMAC if provider.isJdkDefault -> "Default JDK provider doesn't support AES-CMAC, only supported with BouncyCastle" RSA.PSS if provider.isJdkDefault && platform.isAndroid -> "JDK provider on Android doesn't support RSASSA-PSS" + // CryptoKit does not expose RSA primitives + RSA.PSS if provider.isCryptoKit -> "CryptoKit RSA-PSS" + RSA.OAEP if provider.isCryptoKit -> "CryptoKit RSA-OAEP" + RSA.PKCS1 if provider.isCryptoKit -> "CryptoKit RSA-PKCS1" + RSA.RAW if provider.isCryptoKit -> "CryptoKit RSA-RAW" + // No explicit WebCrypto skips: let engines handle availability + // Some JDKs used in CI (jvm / jvm11) lack these algorithms in the default provider + EdDSA if provider.isJdkDefault && platform.isJdk { major < 15 } -> "Default JDK may not support EdDSA before JDK 15" + XDH if provider.isJdkDefault && platform.isJdk { major < 11 } -> "Default JDK may not support XDH before JDK 11" else -> null } }