From 2556ccd1f9ed8e6e35abc3aabf2d2c2b9e7b0b86 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 7 Aug 2026 14:43:49 -0400 Subject: [PATCH 1/2] feat(kmp): convert sha256/sha512/hmac to KMP modules via kotlincrypto --- gradle/libs.versions.toml | 4 + kmp/shared-core/build.gradle.kts | 6 + libs/encryption/hmac/build.gradle.kts | 32 +- .../kotlin/com/getcode/crypt/Hmac.kt | 40 +++ .../kotlin/com/getcode/crypt/HmacTest.kt | 14 +- .../src/main/java/com/getcode/crypt/Hmac.java | 40 --- libs/encryption/sha256/build.gradle.kts | 35 ++- .../com/getcode/crypt/Sha256HashJvmTest.kt | 14 + .../kotlin/com/getcode/crypt/Sha256HashJvm.kt | 21 ++ .../kotlin/com/getcode/crypt/Sha256Hash.kt | 186 +++++++++++ .../com/getcode/crypt/Sha256HashTest.kt | 38 +-- .../java/com/getcode/crypt/Sha256Hash.java | 293 ------------------ libs/encryption/sha512/build.gradle.kts | 32 +- .../kotlin/com/getcode/crypt/PBKDF2SHA512.kt | 85 +++++ .../com/getcode/crypt/PBKDF2SHA512Test.kt | 11 +- .../java/com/getcode/crypt/PBKDF2SHA512.java | 115 ------- 16 files changed, 469 insertions(+), 497 deletions(-) create mode 100644 libs/encryption/hmac/src/commonMain/kotlin/com/getcode/crypt/Hmac.kt rename libs/encryption/hmac/src/{test => commonTest}/kotlin/com/getcode/crypt/HmacTest.kt (85%) delete mode 100644 libs/encryption/hmac/src/main/java/com/getcode/crypt/Hmac.java create mode 100644 libs/encryption/sha256/src/androidHostTest/kotlin/com/getcode/crypt/Sha256HashJvmTest.kt create mode 100644 libs/encryption/sha256/src/androidMain/kotlin/com/getcode/crypt/Sha256HashJvm.kt create mode 100644 libs/encryption/sha256/src/commonMain/kotlin/com/getcode/crypt/Sha256Hash.kt rename libs/encryption/sha256/src/{test => commonTest}/kotlin/com/getcode/crypt/Sha256HashTest.kt (82%) delete mode 100644 libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java create mode 100644 libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/PBKDF2SHA512.kt rename libs/encryption/sha512/src/{test => commonTest}/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt (89%) delete mode 100644 libs/encryption/sha512/src/main/java/com/getcode/crypt/PBKDF2SHA512.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d427075970..204b2bc6d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,8 @@ timber = "5.0.1" sodium-bindings = "0.9.5" desugaring = "2.1.5" +kotlincrypto-hash = "0.7.0" +kotlincrypto-macs = "0.7.0" event-bus = "0.1.0" bugsnag = "6.26.1" @@ -257,6 +259,8 @@ mixpanel = { module = "com.mixpanel.android:mixpanel-android", version.ref = "mi # Crypto sodium-bindings = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings-android", version.ref = "sodium-bindings" } eddsa = { module = "net.i2p.crypto:eddsa", version = "0.3.0" } +kotlincrypto-hash-sha2 = { module = "org.kotlincrypto.hash:sha2", version.ref = "kotlincrypto-hash" } +kotlincrypto-macs-hmac-sha2 = { module = "org.kotlincrypto.macs:hmac-sha2", version.ref = "kotlincrypto-macs" } # Misc fingerprint-pro = { module = "com.fingerprint.android:pro", version = "2.4.0" } diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index ccd6d09386..90ab11b4a4 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -21,6 +21,9 @@ kotlin { baseName = "SharedCore" isStatic = true export(project(":libs:encryption:base58")) + export(project(":libs:encryption:sha256")) + export(project(":libs:encryption:sha512")) + export(project(":libs:encryption:hmac")) } } @@ -28,6 +31,9 @@ kotlin { commonMain { dependencies { api(project(":libs:encryption:base58")) + api(project(":libs:encryption:sha256")) + api(project(":libs:encryption:sha512")) + api(project(":libs:encryption:hmac")) } } } diff --git a/libs/encryption/hmac/build.gradle.kts b/libs/encryption/hmac/build.gradle.kts index 34bbc4639e..e54a5aa429 100644 --- a/libs/encryption/hmac/build.gradle.kts +++ b/libs/encryption/hmac/build.gradle.kts @@ -1,12 +1,30 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") } -android { - namespace = "${Gradle.codeNamespace}.encryption.hmac" -} +kotlin { + android { + namespace = "com.getcode.encryption.hmac" + compileSdk = 37 + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() -dependencies { - implementation(libs.grpc.okhttp) - implementation(libs.grpc.kotlin) + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlincrypto.macs.hmac.sha2) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } } diff --git a/libs/encryption/hmac/src/commonMain/kotlin/com/getcode/crypt/Hmac.kt b/libs/encryption/hmac/src/commonMain/kotlin/com/getcode/crypt/Hmac.kt new file mode 100644 index 0000000000..6421f1b21f --- /dev/null +++ b/libs/encryption/hmac/src/commonMain/kotlin/com/getcode/crypt/Hmac.kt @@ -0,0 +1,40 @@ +package com.getcode.crypt + +import org.kotlincrypto.macs.hmac.sha2.HmacSHA256 +import org.kotlincrypto.macs.hmac.sha2.HmacSHA512 + +/** Multiplatform HMAC utilities backed by kotlincrypto. */ +object Hmac { + + /** + * Computes an HMAC over [message] using [key] with the given [algorithm]. + * + * Supported algorithm strings: `"HmacSHA512"`, `"HmacSHA256"`. + */ + fun hmac(algorithm: String, key: ByteArray, message: ByteArray): ByteArray { + return when (algorithm) { + "HmacSHA512" -> { + val mac = HmacSHA512(key) + mac.update(message) + mac.doFinal() + } + "HmacSHA256" -> { + val mac = HmacSHA256(key) + mac.update(message) + mac.doFinal() + } + else -> throw IllegalArgumentException("Unsupported HMAC algorithm: $algorithm") + } + } + + /** Encodes [bytes] as a lowercase hex string. */ + fun bytesToHex(bytes: ByteArray): String { + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = "0123456789abcdef"[v ushr 4] + hexChars[j * 2 + 1] = "0123456789abcdef"[v and 0x0F] + } + return hexChars.concatToString() + } +} diff --git a/libs/encryption/hmac/src/test/kotlin/com/getcode/crypt/HmacTest.kt b/libs/encryption/hmac/src/commonTest/kotlin/com/getcode/crypt/HmacTest.kt similarity index 85% rename from libs/encryption/hmac/src/test/kotlin/com/getcode/crypt/HmacTest.kt rename to libs/encryption/hmac/src/commonTest/kotlin/com/getcode/crypt/HmacTest.kt index a474e2505a..e8d65ca7c9 100644 --- a/libs/encryption/hmac/src/test/kotlin/com/getcode/crypt/HmacTest.kt +++ b/libs/encryption/hmac/src/commonTest/kotlin/com/getcode/crypt/HmacTest.kt @@ -11,7 +11,7 @@ class HmacTest { @Test fun hmacSha512Rfc4231TestCase1() { val key = ByteArray(20) { 0x0b.toByte() } - val message = "Hi There".toByteArray(Charsets.UTF_8) + val message = "Hi There".encodeToByteArray() val result = Hmac.hmac("HmacSHA512", key, message) assertEquals(64, result.size) assertEquals( @@ -25,8 +25,8 @@ class HmacTest { @Test fun hmacSha512Rfc4231TestCase2() { - val key = "Jefe".toByteArray(Charsets.UTF_8) - val message = "what do ya want for nothing?".toByteArray(Charsets.UTF_8) + val key = "Jefe".encodeToByteArray() + val message = "what do ya want for nothing?".encodeToByteArray() val result = Hmac.hmac("HmacSHA512", key, message) assertEquals( "164b7a7bfcf819e2e395fbe73b56e0a3" + @@ -39,8 +39,8 @@ class HmacTest { @Test fun hmacSha256KnownVector() { - val key = "key".toByteArray(Charsets.UTF_8) - val message = "The quick brown fox jumps over the lazy dog".toByteArray(Charsets.UTF_8) + val key = "key".encodeToByteArray() + val message = "The quick brown fox jumps over the lazy dog".encodeToByteArray() val result = Hmac.hmac("HmacSHA256", key, message) assertEquals( "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", @@ -50,8 +50,8 @@ class HmacTest { @Test fun deterministicOutput() { - val key = "k".toByteArray() - val msg = "m".toByteArray() + val key = "k".encodeToByteArray() + val msg = "m".encodeToByteArray() val a = Hmac.hmac("HmacSHA512", key, msg) val b = Hmac.hmac("HmacSHA512", key, msg) assertContentEquals(a, b) diff --git a/libs/encryption/hmac/src/main/java/com/getcode/crypt/Hmac.java b/libs/encryption/hmac/src/main/java/com/getcode/crypt/Hmac.java deleted file mode 100644 index b1bd95d0c7..0000000000 --- a/libs/encryption/hmac/src/main/java/com/getcode/crypt/Hmac.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.getcode.crypt; - - -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -public class Hmac { - private void generateHashWithHmac256(String message, String key) { - try { - final String hashingAlgorithm = "HmacSHA256"; - - byte[] bytes = hmac(hashingAlgorithm, key.getBytes(), message.getBytes()); - - final String messageDigest = bytesToHex(bytes); - - } catch (Exception e) { - e.printStackTrace(); - } - } - - public static byte[] hmac(String algorithm, byte[] key, byte[] message) throws NoSuchAlgorithmException, InvalidKeyException { - Mac mac = Mac.getInstance(algorithm); - mac.init(new SecretKeySpec(key, algorithm)); - return mac.doFinal(message); - } - - public static String bytesToHex(byte[] bytes) { - final char[] hexArray = "0123456789abcdef".toCharArray(); - char[] hexChars = new char[bytes.length * 2]; - for (int j = 0, v; j < bytes.length; j++) { - v = bytes[j] & 0xFF; - hexChars[j * 2] = hexArray[v >>> 4]; - hexChars[j * 2 + 1] = hexArray[v & 0x0F]; - } - return new String(hexChars); - } -} diff --git a/libs/encryption/sha256/build.gradle.kts b/libs/encryption/sha256/build.gradle.kts index fe4c39a5d6..7c220c0bde 100644 --- a/libs/encryption/sha256/build.gradle.kts +++ b/libs/encryption/sha256/build.gradle.kts @@ -1,12 +1,33 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") } -android { - namespace = "${Gradle.codeNamespace}.encryption.sha256" -} +kotlin { + android { + namespace = "com.getcode.encryption.sha256" + compileSdk = 37 + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() -dependencies { - implementation(libs.grpc.okhttp) - implementation(libs.grpc.kotlin) + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlincrypto.hash.sha2) + } + } + androidMain { + // MessageDigest + BigInteger + File — JDK only; no extra Gradle deps. + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } } diff --git a/libs/encryption/sha256/src/androidHostTest/kotlin/com/getcode/crypt/Sha256HashJvmTest.kt b/libs/encryption/sha256/src/androidHostTest/kotlin/com/getcode/crypt/Sha256HashJvmTest.kt new file mode 100644 index 0000000000..f09eb164a2 --- /dev/null +++ b/libs/encryption/sha256/src/androidHostTest/kotlin/com/getcode/crypt/Sha256HashJvmTest.kt @@ -0,0 +1,14 @@ +package com.getcode.crypt + +import kotlin.test.Test +import kotlin.test.assertTrue + +class Sha256HashJvmTest { + + /** [Sha256Hash.toBigInteger] must return a positive value for a non-zero hash. */ + @Test + fun toBigIntegerPositive() { + val hash = Sha256Hash.wrap(ByteArray(32) { 0xff.toByte() }) + assertTrue(hash.toBigInteger().signum() > 0) + } +} diff --git a/libs/encryption/sha256/src/androidMain/kotlin/com/getcode/crypt/Sha256HashJvm.kt b/libs/encryption/sha256/src/androidMain/kotlin/com/getcode/crypt/Sha256HashJvm.kt new file mode 100644 index 0000000000..f182e2eed3 --- /dev/null +++ b/libs/encryption/sha256/src/androidMain/kotlin/com/getcode/crypt/Sha256HashJvm.kt @@ -0,0 +1,21 @@ +package com.getcode.crypt + +import java.io.File +import java.math.BigInteger +import java.security.MessageDigest + +/** Returns a new SHA-256 [MessageDigest] instance (JVM/Android only). */ +fun Sha256Hash.Companion.newDigest(): MessageDigest = + MessageDigest.getInstance("SHA-256") + +/** + * Returns the wrapped bytes interpreted as a positive (unsigned) [BigInteger] + * (JVM/Android only). + */ +fun Sha256Hash.toBigInteger(): BigInteger = BigInteger(1, bytes) + +/** + * Hashes the full contents of [file] and returns the result wrapped in a + * [Sha256Hash] (JVM/Android only). + */ +fun Sha256Hash.Companion.of(file: File): Sha256Hash = of(file.readBytes()) diff --git a/libs/encryption/sha256/src/commonMain/kotlin/com/getcode/crypt/Sha256Hash.kt b/libs/encryption/sha256/src/commonMain/kotlin/com/getcode/crypt/Sha256Hash.kt new file mode 100644 index 0000000000..e45e5d4231 --- /dev/null +++ b/libs/encryption/sha256/src/commonMain/kotlin/com/getcode/crypt/Sha256Hash.kt @@ -0,0 +1,186 @@ +package com.getcode.crypt + +import org.kotlincrypto.hash.sha2.SHA256 + +/* + * Copyright 2011 Google Inc. + * Copyright 2014 Andreas Schildbach + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Wraps a 32-byte SHA-256 hash so that [equals] and [hashCode] work correctly, + * making it safe to use as a map key. Provides factory methods for computing + * single and double SHA-256 hashes. + */ +class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable { + + companion object { + /** The byte length of a SHA-256 hash output. */ + const val LENGTH: Int = 32 + + /** A hash whose bytes are all zeros. */ + val ZERO_HASH: Sha256Hash = wrap(ByteArray(LENGTH)) + + /** Creates a new instance that wraps the given raw hash bytes (must be exactly 32 bytes). */ + fun wrap(rawHashBytes: ByteArray): Sha256Hash { + require(rawHashBytes.size == LENGTH) { + "Expected $LENGTH bytes but got ${rawHashBytes.size}" + } + return Sha256Hash(rawHashBytes) + } + + /** + * Creates a new instance that wraps the given hex-encoded hash value + * (must represent exactly 32 bytes, i.e., 64 hex characters). + */ + fun wrap(hexString: String): Sha256Hash = wrap(decodeHex(hexString)) + + /** Creates a new instance wrapping the given bytes with their order reversed. */ + fun wrapReversed(rawHashBytes: ByteArray): Sha256Hash = wrap(reverseBytes(rawHashBytes)) + + /** Computes a single SHA-256 hash of [contents] and wraps the result. */ + fun of(contents: ByteArray): Sha256Hash = wrap(hash(contents)) + + /** + * Computes a double SHA-256 hash (SHA-256(SHA-256(contents))) of [contents] + * and wraps the result. + */ + fun twiceOf(contents: ByteArray): Sha256Hash = wrap(hashTwice(contents)) + + /** + * Computes a double SHA-256 hash over the concatenation of [content1] and [content2] + * and wraps the result. + */ + fun twiceOf(content1: ByteArray, content2: ByteArray): Sha256Hash = + wrap(hashTwice(content1, content2)) + + /** Calculates the SHA-256 hash of [input]. */ + fun hash(input: ByteArray): ByteArray = hash(input, 0, input.size) + + /** Calculates the SHA-256 hash of [length] bytes from [input] starting at [offset]. */ + fun hash(input: ByteArray, offset: Int, length: Int): ByteArray { + val digest = SHA256() + digest.update(input, offset, length) + return digest.digest() + } + + /** Calculates SHA-256(SHA-256([input])). */ + fun hashTwice(input: ByteArray): ByteArray = hashTwice(input, 0, input.size) + + /** + * Calculates SHA-256(SHA-256([input1] || [input2])), equivalent to concatenating + * the two arrays and calling [hashTwice]. + */ + fun hashTwice(input1: ByteArray, input2: ByteArray): ByteArray { + val first = SHA256().run { + update(input1) + update(input2) + digest() + } + return SHA256().also { it.update(first) }.digest() + } + + /** Calculates SHA-256(SHA-256([length] bytes of [input] starting at [offset])). */ + fun hashTwice(input: ByteArray, offset: Int, length: Int): ByteArray { + val first = SHA256().also { it.update(input, offset, length) }.digest() + return SHA256().also { it.update(first) }.digest() + } + + /** + * Calculates SHA-256(SHA-256([input1][offset1]..[length1] || [input2][offset2]..[length2])). + */ + fun hashTwice( + input1: ByteArray, offset1: Int, length1: Int, + input2: ByteArray, offset2: Int, length2: Int, + ): ByteArray { + val first = SHA256().run { + update(input1, offset1, length1) + update(input2, offset2, length2) + digest() + } + return SHA256().also { it.update(first) }.digest() + } + + // ── helpers ────────────────────────────────────────────────────────── + + private fun reverseBytes(src: ByteArray): ByteArray { + val buf = ByteArray(src.size) + for (i in src.indices) buf[i] = src[src.size - 1 - i] + return buf + } + + private fun decodeHex(hex: String): ByteArray { + require(hex.length % 2 == 0) { "Hex string must have even length" } + val result = ByteArray(hex.length / 2) + for (i in result.indices) { + val hi = hex[i * 2].digitToInt(16) + val lo = hex[i * 2 + 1].digitToInt(16) + result[i] = ((hi shl 4) or lo).toByte() + } + return result + } + } + + /** Returns the raw hash bytes. Do NOT modify the returned array. */ + fun getBytes(): ByteArray = bytes + + /** Returns a reversed copy of the internal byte array. */ + fun getReversedBytes(): ByteArray { + val buf = ByteArray(bytes.size) + for (i in bytes.indices) buf[i] = bytes[bytes.size - 1 - i] + return buf + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Sha256Hash) return false + return bytes.contentEquals(other.bytes) + } + + /** + * Returns a hash code derived from the last four bytes of the wrapped hash. + * Those bytes are typically non-zero even for proof-of-work hashes. + */ + override fun hashCode(): Int = + ((bytes[LENGTH - 4].toInt() and 0xFF) shl 24) or + ((bytes[LENGTH - 3].toInt() and 0xFF) shl 16) or + ((bytes[LENGTH - 2].toInt() and 0xFF) shl 8) or + (bytes[LENGTH - 1].toInt() and 0xFF) + + /** Returns the lowercase hex representation of the hash bytes. */ + override fun toString(): String { + val sb = StringBuilder(bytes.size * 2) + for (b in bytes) { + val v = b.toInt() and 0xFF + sb.append("0123456789abcdef"[v ushr 4]) + sb.append("0123456789abcdef"[v and 0x0F]) + } + return sb.toString() + } + + /** + * Compares hashes in reverse-byte order (little-endian numeric comparison), + * consistent with Bitcoin's natural ordering. + */ + override fun compareTo(other: Sha256Hash): Int { + for (i in LENGTH - 1 downTo 0) { + val a = bytes[i].toInt() and 0xFF + val b = other.bytes[i].toInt() and 0xFF + if (a > b) return 1 + if (a < b) return -1 + } + return 0 + } +} diff --git a/libs/encryption/sha256/src/test/kotlin/com/getcode/crypt/Sha256HashTest.kt b/libs/encryption/sha256/src/commonTest/kotlin/com/getcode/crypt/Sha256HashTest.kt similarity index 82% rename from libs/encryption/sha256/src/test/kotlin/com/getcode/crypt/Sha256HashTest.kt rename to libs/encryption/sha256/src/commonTest/kotlin/com/getcode/crypt/Sha256HashTest.kt index ae90549c59..cd11a38b72 100644 --- a/libs/encryption/sha256/src/test/kotlin/com/getcode/crypt/Sha256HashTest.kt +++ b/libs/encryption/sha256/src/commonTest/kotlin/com/getcode/crypt/Sha256HashTest.kt @@ -8,7 +8,15 @@ import kotlin.test.assertTrue class Sha256HashTest { - private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + private fun ByteArray.toHex(): String { + val sb = StringBuilder(size * 2) + for (b in this) { + val v = b.toInt() and 0xFF + sb.append("0123456789abcdef"[v ushr 4]) + sb.append("0123456789abcdef"[v and 0x0F]) + } + return sb.toString() + } // --- Known SHA-256 vectors (NIST FIPS 180-4) --- @@ -24,7 +32,7 @@ class Sha256HashTest { @Test fun hashAbc() { - val result = Sha256Hash.hash("abc".toByteArray(Charsets.UTF_8)) + val result = Sha256Hash.hash("abc".encodeToByteArray()) assertEquals( "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", result.toHex(), @@ -33,8 +41,8 @@ class Sha256HashTest { @Test fun hashWithOffsetAndLength() { - val input = "XXabcYY".toByteArray(Charsets.UTF_8) - val full = Sha256Hash.hash("abc".toByteArray(Charsets.UTF_8)) + val input = "XXabcYY".encodeToByteArray() + val full = Sha256Hash.hash("abc".encodeToByteArray()) val partial = Sha256Hash.hash(input, 2, 3) assertTrue(full.contentEquals(partial)) } @@ -43,7 +51,7 @@ class Sha256HashTest { @Test fun hashTwiceDiffersFromSingleHash() { - val input = "abc".toByteArray(Charsets.UTF_8) + val input = "abc".encodeToByteArray() val once = Sha256Hash.hash(input) val twice = Sha256Hash.hashTwice(input) assertNotEquals(once.toHex(), twice.toHex()) @@ -51,7 +59,7 @@ class Sha256HashTest { @Test fun hashTwiceEqualsHashOfHash() { - val input = "abc".toByteArray(Charsets.UTF_8) + val input = "abc".encodeToByteArray() val twice = Sha256Hash.hashTwice(input) val manual = Sha256Hash.hash(Sha256Hash.hash(input)) assertTrue(twice.contentEquals(manual)) @@ -59,8 +67,8 @@ class Sha256HashTest { @Test fun hashTwiceTwoInputs() { - val a = "abc".toByteArray(Charsets.UTF_8) - val b = "def".toByteArray(Charsets.UTF_8) + val a = "abc".encodeToByteArray() + val b = "def".encodeToByteArray() val combined = Sha256Hash.hashTwice(a + b) val split = Sha256Hash.hashTwice(a, b) assertTrue(combined.contentEquals(split)) @@ -93,7 +101,7 @@ class Sha256HashTest { @Test fun ofWrapsHash() { - val input = "test".toByteArray(Charsets.UTF_8) + val input = "test".encodeToByteArray() val hash = Sha256Hash.of(input) assertTrue(Sha256Hash.hash(input).contentEquals(hash.bytes)) } @@ -102,7 +110,7 @@ class Sha256HashTest { @Test fun twiceOfWrapsHashTwice() { - val input = "test".toByteArray(Charsets.UTF_8) + val input = "test".encodeToByteArray() val hash = Sha256Hash.twiceOf(input) assertTrue(Sha256Hash.hashTwice(input).contentEquals(hash.bytes)) } @@ -148,7 +156,7 @@ class Sha256HashTest { @Test fun toStringIsLowercaseHex() { - val hash = Sha256Hash.of("abc".toByteArray(Charsets.UTF_8)) + val hash = Sha256Hash.of("abc".encodeToByteArray()) val str = hash.toString() assertEquals(64, str.length) assertTrue(str.all { it in '0'..'9' || it in 'a'..'f' }) @@ -163,12 +171,4 @@ class Sha256HashTest { assertEquals(raw[0], reversed.bytes[31]) assertEquals(raw[31], reversed.bytes[0]) } - - // --- toBigInteger --- - - @Test - fun toBigIntegerPositive() { - val hash = Sha256Hash.wrap(ByteArray(32) { 0xff.toByte() }) - assertTrue(hash.toBigInteger().signum() > 0) - } } diff --git a/libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java b/libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java deleted file mode 100644 index db504bc8cf..0000000000 --- a/libs/encryption/sha256/src/main/java/com/getcode/crypt/Sha256Hash.java +++ /dev/null @@ -1,293 +0,0 @@ -package com.getcode.crypt; - -/* - * Copyright 2011 Google Inc. - * Copyright 2014 Andreas Schildbach - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.google.common.base.Preconditions; -import com.google.common.io.BaseEncoding; -import com.google.common.io.ByteStreams; -import com.google.common.primitives.*; - -import java.io.File; -import java.io.FileInputStream; -import java.io.IOException; -import java.io.Serializable; -import java.math.BigInteger; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Arrays; - -import static com.google.common.base.Preconditions.checkArgument; - -/** - * A {@code Sha256Hash} wraps a {@code byte[]} so that {@link #equals} and {@link #hashCode} work correctly, allowing it to be used as a key in a - * map. It also checks that the {@code length} is correct (equal to {@link #LENGTH}) and provides a bit more type safety. - *

- * Given that {@code Sha256Hash} instances can be created using {@link #wrapReversed(byte[])} or {@link #twiceOf(byte[])} or by wrapping raw bytes, there is no guarantee that if two {@code Sha256Hash} instances are found equal (via {@link #equals(Object)}) that their preimages would be the same (even in the absence of a hash collision.) - */ -public class Sha256Hash implements Serializable, Comparable { - public static final int LENGTH = 32; // bytes - public static final Sha256Hash ZERO_HASH = wrap(new byte[LENGTH]); - - private final byte[] bytes; - - private Sha256Hash(byte[] rawHashBytes) { - Preconditions.checkArgument(rawHashBytes.length == LENGTH); - this.bytes = rawHashBytes; - } - - /** - * Creates a new instance that wraps the given hash value. - * - * @param rawHashBytes the raw hash bytes to wrap - * @return a new instance - * @throws IllegalArgumentException if the given array length is not exactly 32 - */ - public static Sha256Hash wrap(byte[] rawHashBytes) { - return new Sha256Hash(rawHashBytes); - } - - /** - * Creates a new instance that wraps the given hash value (represented as a hex string). - * - * @param hexString a hash value represented as a hex string - * @return a new instance - * @throws IllegalArgumentException if the given string is not a valid - * hex string, or if it does not represent exactly 32 bytes - */ - public static Sha256Hash wrap(String hexString) { - return wrap(HEX.decode(hexString)); - } - - /** - * Creates a new instance that wraps the given hash value, but with byte order reversed. - * - * @param rawHashBytes the raw hash bytes to wrap - * @return a new instance - * @throws IllegalArgumentException if the given array length is not exactly 32 - */ - public static Sha256Hash wrapReversed(byte[] rawHashBytes) { - return wrap(reverseBytes(rawHashBytes)); - } - - /** - * Creates a new instance containing the calculated (one-time) hash of the given bytes. - * - * @param contents the bytes on which the hash value is calculated - * @return a new instance containing the calculated (one-time) hash - */ - public static Sha256Hash of(byte[] contents) { - return wrap(hash(contents)); - } - - /** - * Creates a new instance containing the hash of the calculated hash of the given bytes. - * - * @param contents the bytes on which the hash value is calculated - * @return a new instance containing the calculated (two-time) hash - */ - public static Sha256Hash twiceOf(byte[] contents) { - return wrap(hashTwice(contents)); - } - - /** - * Creates a new instance containing the hash of the calculated hash of the given bytes. - * - * @param content1 first bytes on which the hash value is calculated - * @param content2 second bytes on which the hash value is calculated - * @return a new instance containing the calculated (two-time) hash - */ - public static Sha256Hash twiceOf(byte[] content1, byte[] content2) { - return wrap(hashTwice(content1, content2)); - } - - /** - * Creates a new instance containing the calculated (one-time) hash of the given file's contents. - * - * The file contents are read fully into memory, so this method should only be used with small files. - * - * @param file the file on which the hash value is calculated - * @return a new instance containing the calculated (one-time) hash - * @throws IOException if an error occurs while reading the file - */ - public static Sha256Hash of(File file) throws IOException { - try (FileInputStream in = new FileInputStream(file)) { - return of(ByteStreams.toByteArray(in)); - } - } - - /** - * Returns a new SHA-256 MessageDigest instance. - * - * This is a convenience method which wraps the checked - * exception that can never occur with a RuntimeException. - * - * @return a new SHA-256 MessageDigest instance - */ - public static MessageDigest newDigest() { - try { - return MessageDigest.getInstance("SHA-256"); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); // Can't happen. - } - } - - /** - * Calculates the SHA-256 hash of the given bytes. - * - * @param input the bytes to hash - * @return the hash (in big-endian order) - */ - public static byte[] hash(byte[] input) { - return hash(input, 0, input.length); - } - - /** - * Calculates the SHA-256 hash of the given byte range. - * - * @param input the array containing the bytes to hash - * @param offset the offset within the array of the bytes to hash - * @param length the number of bytes to hash - * @return the hash (in big-endian order) - */ - public static byte[] hash(byte[] input, int offset, int length) { - MessageDigest digest = newDigest(); - digest.update(input, offset, length); - return digest.digest(); - } - - /** - * Calculates the SHA-256 hash of the given bytes, - * and then hashes the resulting hash again. - * - * @param input the bytes to hash - * @return the double-hash (in big-endian order) - */ - public static byte[] hashTwice(byte[] input) { - return hashTwice(input, 0, input.length); - } - - /** - * Calculates the hash of hash on the given chunks of bytes. This is equivalent to concatenating the two - * chunks and then passing the result to {@link #hashTwice(byte[])}. - */ - public static byte[] hashTwice(byte[] input1, byte[] input2) { - MessageDigest digest = newDigest(); - digest.update(input1); - digest.update(input2); - return digest.digest(digest.digest()); - } - - /** - * Calculates the SHA-256 hash of the given byte range, - * and then hashes the resulting hash again. - * - * @param input the array containing the bytes to hash - * @param offset the offset within the array of the bytes to hash - * @param length the number of bytes to hash - * @return the double-hash (in big-endian order) - */ - public static byte[] hashTwice(byte[] input, int offset, int length) { - MessageDigest digest = newDigest(); - digest.update(input, offset, length); - return digest.digest(digest.digest()); - } - - /** - * Calculates the hash of hash on the given byte ranges. This is equivalent to - * concatenating the two ranges and then passing the result to {@link #hashTwice(byte[])}. - */ - public static byte[] hashTwice(byte[] input1, int offset1, int length1, - byte[] input2, int offset2, int length2) { - MessageDigest digest = newDigest(); - digest.update(input1, offset1, length1); - digest.update(input2, offset2, length2); - return digest.digest(digest.digest()); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - return Arrays.equals(bytes, ((Sha256Hash)o).bytes); - } - - /** - * Returns the last four bytes of the wrapped hash. This should be unique enough to be a suitable hash code even for - * blocks, where the goal is to try and get the first bytes to be zeros (i.e. the value as a big integer lower - * than the target value). - */ - @Override - public int hashCode() { - // Use the last 4 bytes, not the first 4 which are often zeros in Bitcoin. - return Ints.fromBytes(bytes[LENGTH - 4], bytes[LENGTH - 3], bytes[LENGTH - 2], bytes[LENGTH - 1]); - } - - @Override - public String toString() { - return HEX.encode(bytes); - } - - /** - * Returns the bytes interpreted as a positive integer. - */ - public BigInteger toBigInteger() { - return bytesToBigInteger(bytes); - } - - /** - * Returns the internal byte array, without defensively copying. Therefore do NOT modify the returned array. - */ - public byte[] getBytes() { - return bytes; - } - - /** - * Returns a reversed copy of the internal byte array. - */ - public byte[] getReversedBytes() { - return reverseBytes(bytes); - } - - @Override - public int compareTo(final Sha256Hash other) { - for (int i = LENGTH - 1; i >= 0; i--) { - final int thisByte = this.bytes[i] & 0xff; - final int otherByte = other.bytes[i] & 0xff; - if (thisByte > otherByte) - return 1; - if (thisByte < otherByte) - return -1; - } - return 0; - } - - private static final BaseEncoding HEX = BaseEncoding.base16().lowerCase(); - - private static byte[] reverseBytes(byte[] bytes) { - // We could use the XOR trick here but it's easier to understand if we don't. If we find this is really a - // performance issue the matter can be revisited. - byte[] buf = new byte[bytes.length]; - for (int i = 0; i < bytes.length; i++) - buf[i] = bytes[bytes.length - 1 - i]; - return buf; - } - - private static BigInteger bytesToBigInteger(byte[] bytes) { - return new BigInteger(1, bytes); - } -} \ No newline at end of file diff --git a/libs/encryption/sha512/build.gradle.kts b/libs/encryption/sha512/build.gradle.kts index a069c00259..491bda4cdc 100644 --- a/libs/encryption/sha512/build.gradle.kts +++ b/libs/encryption/sha512/build.gradle.kts @@ -1,12 +1,30 @@ plugins { - alias(libs.plugins.flipcash.android.library) + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") } -android { - namespace = "${Gradle.codeNamespace}.encryption.sha512" -} +kotlin { + android { + namespace = "com.getcode.encryption.sha512" + compileSdk = 37 + minSdk = 29 + withHostTest {} + } + + iosArm64() + iosSimulatorArm64() + iosX64() -dependencies { - implementation(libs.grpc.okhttp) - implementation(libs.grpc.kotlin) + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlincrypto.macs.hmac.sha2) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + } } diff --git a/libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/PBKDF2SHA512.kt b/libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/PBKDF2SHA512.kt new file mode 100644 index 0000000000..2b4032ee4d --- /dev/null +++ b/libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/PBKDF2SHA512.kt @@ -0,0 +1,85 @@ +package com.getcode.crypt + +import org.kotlincrypto.macs.hmac.sha2.HmacSHA512 + +/* + * Copyright (c) 2012 Cole Barnes [cryptofreek{at}gmail{dot}com] + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Pure-Kotlin PBKDF2-HMAC-SHA512 implementation following RFC 2898 §5.2. + * Passes all RFC 6070 test vectors (adapted for SHA-512). + */ +object PBKDF2SHA512 { + + private const val H_LEN = 64 // HmacSHA512 output size in bytes + + /** + * Derives a key of [dkLen] bytes from password [P] and salt [S] using [c] + * PBKDF2 iterations with HMAC-SHA-512 as the pseudorandom function. + */ + fun derive(P: String, S: String, c: Int, dkLen: Int): ByteArray { + require(dkLen > 0) { "dkLen must be positive" } + require(dkLen.toLong() <= (0xFFFFFFFFL) * H_LEN) { "derived key too long" } + + val password = P.encodeToByteArray() + val salt = S.encodeToByteArray() + val l = (dkLen + H_LEN - 1) / H_LEN + val dk = ByteArray(dkLen) + var offset = 0 + + for (i in 1..l) { + val t = f(password, salt, c, i) + val copyLen = minOf(H_LEN, dkLen - offset) + t.copyInto(dk, offset, 0, copyLen) + offset += copyLen + } + return dk + } + + /** Computes the T_i block as defined in RFC 2898 §5.2. */ + private fun f(password: ByteArray, salt: ByteArray, c: Int, i: Int): ByteArray { + // INT(i) — 4-byte big-endian representation of i + val iBytes = byteArrayOf( + (i shr 24).toByte(), + (i shr 16).toByte(), + (i shr 8).toByte(), + i.toByte(), + ) + + // U_1 = HMAC(Password, Salt || INT(i)) + val hmac1 = HmacSHA512(password) + hmac1.update(salt) + hmac1.update(iBytes) + var u = hmac1.doFinal() + val xor = u.copyOf() + + // U_j = HMAC(Password, U_{j-1}), for j = 2..c + for (j in 2..c) { + val hmacJ = HmacSHA512(password) + hmacJ.update(u) + u = hmacJ.doFinal() + for (k in xor.indices) xor[k] = (xor[k].toInt() xor u[k].toInt()).toByte() + } + + return xor + } +} diff --git a/libs/encryption/sha512/src/test/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt b/libs/encryption/sha512/src/commonTest/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt similarity index 89% rename from libs/encryption/sha512/src/test/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt rename to libs/encryption/sha512/src/commonTest/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt index 1ce4b16ebd..0f17fd6284 100644 --- a/libs/encryption/sha512/src/test/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt +++ b/libs/encryption/sha512/src/commonTest/kotlin/com/getcode/crypt/PBKDF2SHA512Test.kt @@ -4,11 +4,18 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertTrue class PBKDF2SHA512Test { - private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } + private fun ByteArray.toHex(): String { + val sb = StringBuilder(size * 2) + for (b in this) { + val v = b.toInt() and 0xFF + sb.append("0123456789abcdef"[v ushr 4]) + sb.append("0123456789abcdef"[v and 0x0F]) + } + return sb.toString() + } // Known PBKDF2-HMAC-SHA512 vector: // Python: hashlib.pbkdf2_hmac('sha512', b'password', b'salt', 1, 64) diff --git a/libs/encryption/sha512/src/main/java/com/getcode/crypt/PBKDF2SHA512.java b/libs/encryption/sha512/src/main/java/com/getcode/crypt/PBKDF2SHA512.java deleted file mode 100644 index 0ad64bbac2..0000000000 --- a/libs/encryption/sha512/src/main/java/com/getcode/crypt/PBKDF2SHA512.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.getcode.crypt; - -/* - * Copyright (c) 2012 Cole Barnes [cryptofreek{at}gmail{dot}com] - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - */ - - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; - -/** - *

This is a clean-room implementation of PBKDF2 using RFC 2898 as a reference.

- * - *

RFC 2898: http://tools.ietf.org/html/rfc2898#section-5.2

- * - *

This code passes all RFC 6070 test vectors: http://tools.ietf.org/html/rfc6070

- * - *

http://cryptofreek.org/2012/11/29/pbkdf2-pure-java-implementation/
- * Modified to use SHA-512 - Ken Sedgwick ken@bonsai.com

- */ -public class PBKDF2SHA512 { - public static byte[] derive(String P, String S, int c, int dkLen) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - try { - int hLen = 20; - - if (dkLen > ((Math.pow(2, 32)) - 1) * hLen) { - throw new IllegalArgumentException("derived key too long"); - } else { - int l = (int) Math.ceil((double) dkLen / (double) hLen); - // int r = dkLen - (l-1)*hLen; - - for (int i = 1; i <= l; i++) { - byte[] T = F(P, S, c, i); - baos.write(T); - } - } - } catch (Exception e) { - throw new RuntimeException(e); - } - - byte[] baDerived = new byte[dkLen]; - System.arraycopy(baos.toByteArray(), 0, baDerived, 0, baDerived.length); - - return baDerived; - } - - private static byte[] F(String P, String S, int c, int i) throws Exception { - byte[] U_LAST = null; - byte[] U_XOR = null; - - SecretKeySpec key = new SecretKeySpec(P.getBytes(StandardCharsets.UTF_8), "HmacSHA512"); - Mac mac = Mac.getInstance(key.getAlgorithm()); - mac.init(key); - - for (int j = 0; j < c; j++) { - if (j == 0) { - byte[] baS = S.getBytes(StandardCharsets.UTF_8); - byte[] baI = INT(i); - byte[] baU = new byte[baS.length + baI.length]; - - System.arraycopy(baS, 0, baU, 0, baS.length); - System.arraycopy(baI, 0, baU, baS.length, baI.length); - - U_XOR = mac.doFinal(baU); - U_LAST = U_XOR; - mac.reset(); - } else { - byte[] baU = mac.doFinal(U_LAST); - mac.reset(); - - for (int k = 0; k < U_XOR.length; k++) { - U_XOR[k] = (byte) (U_XOR[k] ^ baU[k]); - } - - U_LAST = baU; - } - } - - return U_XOR; - } - - private static byte[] INT(int i) { - ByteBuffer bb = ByteBuffer.allocate(4); - bb.order(ByteOrder.BIG_ENDIAN); - bb.putInt(i); - - return bb.array(); - } -} \ No newline at end of file From 3b1e398bbaff00a806a7d729ec56bf5e7447f523 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 7 Aug 2026 15:04:43 -0400 Subject: [PATCH 2/2] fix(kmp): wire KMP modules' testAndroidHostTest into flipcashTestDebug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KMP library modules (com.android.kotlin.multiplatform.library + withHostTest {}) expose `testAndroidHostTest`, not the `testDebugUnitTest` that com.android.library modules expose. The flipcashTestDebug aggregate only bucketed modules into android (`testDebugUnitTest`) and pure-JVM (`test`), so once base58 became a KMP module (#1201) it landed in the android bucket and the aggregate depended on a non-existent `:libs:encryption:base58:testDebugUnitTest` — silently skipping / breaking the base58 vector gate in the aggregate. This PR's sha256/sha512/hmac KMP conversions would extend the same fault. Add a kmpUnitTestModules list (base58 + sha256/sha512/hmac), exclude it from the android bucket, exclude the testless :kmp:shared-core umbrella, and depend on `testAndroidHostTest` for the KMP modules so their host tests run in the aggregate. Verified: `./gradlew flipcashTestDebug --dry-run` resolves the full task graph (exit 0) with the KMP host-test tasks wired in. --- build.gradle.kts | 6 +++++- settings.gradle.kts | 24 ++++++++++++++++++------ 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 265a2cbf24..1d3f166c0d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -66,14 +66,18 @@ dependencies { // Isolated Projects forbids. The module lists come from `settings.gradle.kts`; the // task depends on each module's test task by *path* — a lazy, cross-project-safe // dependency that never configures the other project at configuration time. -// Android modules expose `testDebugUnitTest`; pure-JVM modules expose `test`. +// Android modules expose `testDebugUnitTest`; KMP modules expose `testAndroidHostTest`; +// pure-JVM modules expose `test`. @Suppress("UNCHECKED_CAST") val androidUnitTestModules = rootProject.extra["flipcash.androidUnitTestModules"] as List @Suppress("UNCHECKED_CAST") val jvmUnitTestModules = rootProject.extra["flipcash.jvmUnitTestModules"] as List +@Suppress("UNCHECKED_CAST") +val kmpUnitTestModules = rootProject.extra["flipcash.kmpUnitTestModules"] as List tasks.register("flipcashTestDebug") { description = "Run testDebug for all Flipcash modules" dependsOn(androidUnitTestModules.map { "$it:testDebugUnitTest" }) dependsOn(jvmUnitTestModules.map { "$it:test" }) + dependsOn(kmpUnitTestModules.map { "$it:testAndroidHostTest" }) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9759367a10..fc23b06914 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -239,17 +239,27 @@ val koverModules = includedProjectPaths.filter { path -> koverPaths.any { path == it || path.startsWith("$it:") } && path !in nonKoverModules } -// Aggregate unit tests: :apps:flipcash, service modules, and :libs (which holds the host-JVM -// cross-platform vector gate for base58 + any future pure-JVM lib tests). Android modules expose -// `testDebugUnitTest`; pure-JVM modules expose `test`; the androidTest `:benchmark` module has -// no unit-test task. +// Aggregate unit tests: :apps:flipcash, service modules, :libs (host-JVM cross-platform vector +// gates + pure-JVM lib tests), and :kmp. Android library modules expose `testDebugUnitTest`; +// KMP modules (`com.android.kotlin.multiplatform.library` + `withHostTest {}`) expose +// `testAndroidHostTest`; pure-JVM modules expose `test`; `:benchmark` is androidTest-only and the +// `:kmp:shared-core` umbrella has no tests of its own. val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:opencode", ":libs", ":kmp") val jvmUnitTestModules = setOf(":apps:flipcash:shared:ksp") -val noUnitTestModules = setOf(":apps:flipcash:benchmark") +// KMP modules run their host tests via `testAndroidHostTest`, not `testDebugUnitTest`. +val kmpUnitTestModules = setOf( + ":libs:encryption:base58", + ":libs:encryption:sha256", + ":libs:encryption:sha512", + ":libs:encryption:hmac", +) +val noUnitTestModules = setOf(":apps:flipcash:benchmark", ":kmp:shared-core") val unitTestCandidates = includedProjectPaths.filter { path -> unitTestPaths.any { path == it || path.startsWith("$it:") } && path !in noUnitTestModules } -val androidUnitTestModules = unitTestCandidates.filter { it !in jvmUnitTestModules } +val androidUnitTestModules = unitTestCandidates.filter { + it !in jvmUnitTestModules && it !in kmpUnitTestModules +} // Forced dependency versions for the per-project configuration below. The // version catalog isn't registered on projects yet at `beforeProject` time, so @@ -277,6 +287,7 @@ run { val koverModulesForRoot = koverModules.toList() val androidUnitTestForRoot = androidUnitTestModules.toList() val jvmUnitTestForRoot = jvmUnitTestModules.toList() + val kmpUnitTestForRoot = kmpUnitTestModules.toList() val forcedDependencies = listOf( "org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion", "org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion", @@ -287,6 +298,7 @@ run { extra["flipcash.koverModules"] = koverModulesForRoot extra["flipcash.androidUnitTestModules"] = androidUnitTestForRoot extra["flipcash.jvmUnitTestModules"] = jvmUnitTestForRoot + extra["flipcash.kmpUnitTestModules"] = kmpUnitTestForRoot } configurations.configureEach {