From fdbd4148609d214d5921fa308161307206db82c6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 7 Aug 2026 16:19:53 -0400 Subject: [PATCH 1/2] fix(kmp): restore Java interop for sha256/sha512 KMP modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sha256Hash.bytes: internal → public val (restores cross-module Kotlin access and the auto-generated Java getBytes() accessor; removes now- redundant explicit getBytes() method that would clash with the getter) - Sha256Hash companion functions: add @JvmStatic so Java callers can resolve hash(), hashTwice(), wrap(), wrapReversed(), of(), twiceOf() as static methods - PBKDF2SHA512.derive: add @JvmStatic so Java callers can call PBKDF2SHA512.derive(...) from a static context - MnemonicCode.java: replace Sha256Hash.newDigest() (an androidMain extension, not Java-callable) with an inline MessageDigest.getInstance call; Sha256Hash.hash() now resolves via @JvmStatic above --- .../java/com/getcode/crypt/MnemonicCode.java | 4 +++- .../kotlin/com/getcode/crypt/Sha256Hash.kt | 18 ++++++++++++++---- .../kotlin/com/getcode/crypt/PBKDF2SHA512.kt | 2 ++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java b/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java index ba02c5d39c..b4ae23c579 100644 --- a/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java +++ b/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java @@ -92,7 +92,9 @@ public MnemonicCode(InputStream wordstream, String wordListDigest) throws IOExce if (wordstream == null) return; BufferedReader br = new BufferedReader(new InputStreamReader(wordstream, StandardCharsets.UTF_8)); this.wordList = new ArrayList<>(2048); - MessageDigest md = Sha256Hash.newDigest(); + MessageDigest md; + try { md = MessageDigest.getInstance("SHA-256"); } + catch (java.security.NoSuchAlgorithmException e) { throw new RuntimeException(e); } String word; while ((word = br.readLine()) != null) { md.update(word.getBytes()); 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 index e45e5d4231..cbfdf7ec5b 100644 --- a/libs/encryption/sha256/src/commonMain/kotlin/com/getcode/crypt/Sha256Hash.kt +++ b/libs/encryption/sha256/src/commonMain/kotlin/com/getcode/crypt/Sha256Hash.kt @@ -1,5 +1,6 @@ package com.getcode.crypt +import kotlin.jvm.JvmStatic import org.kotlincrypto.hash.sha2.SHA256 /* @@ -24,7 +25,7 @@ import org.kotlincrypto.hash.sha2.SHA256 * 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 { +class Sha256Hash private constructor(val bytes: ByteArray) : Comparable { companion object { /** The byte length of a SHA-256 hash output. */ @@ -34,6 +35,7 @@ class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable val ZERO_HASH: Sha256Hash = wrap(ByteArray(LENGTH)) /** Creates a new instance that wraps the given raw hash bytes (must be exactly 32 bytes). */ + @JvmStatic fun wrap(rawHashBytes: ByteArray): Sha256Hash { require(rawHashBytes.size == LENGTH) { "Expected $LENGTH bytes but got ${rawHashBytes.size}" @@ -45,31 +47,38 @@ class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable * Creates a new instance that wraps the given hex-encoded hash value * (must represent exactly 32 bytes, i.e., 64 hex characters). */ + @JvmStatic fun wrap(hexString: String): Sha256Hash = wrap(decodeHex(hexString)) /** Creates a new instance wrapping the given bytes with their order reversed. */ + @JvmStatic fun wrapReversed(rawHashBytes: ByteArray): Sha256Hash = wrap(reverseBytes(rawHashBytes)) /** Computes a single SHA-256 hash of [contents] and wraps the result. */ + @JvmStatic 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. */ + @JvmStatic 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. */ + @JvmStatic fun twiceOf(content1: ByteArray, content2: ByteArray): Sha256Hash = wrap(hashTwice(content1, content2)) /** Calculates the SHA-256 hash of [input]. */ + @JvmStatic fun hash(input: ByteArray): ByteArray = hash(input, 0, input.size) /** Calculates the SHA-256 hash of [length] bytes from [input] starting at [offset]. */ + @JvmStatic fun hash(input: ByteArray, offset: Int, length: Int): ByteArray { val digest = SHA256() digest.update(input, offset, length) @@ -77,12 +86,14 @@ class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable } /** Calculates SHA-256(SHA-256([input])). */ + @JvmStatic 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]. */ + @JvmStatic fun hashTwice(input1: ByteArray, input2: ByteArray): ByteArray { val first = SHA256().run { update(input1) @@ -93,6 +104,7 @@ class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable } /** Calculates SHA-256(SHA-256([length] bytes of [input] starting at [offset])). */ + @JvmStatic 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() @@ -101,6 +113,7 @@ class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable /** * Calculates SHA-256(SHA-256([input1][offset1]..[length1] || [input2][offset2]..[length2])). */ + @JvmStatic fun hashTwice( input1: ByteArray, offset1: Int, length1: Int, input2: ByteArray, offset2: Int, length2: Int, @@ -133,9 +146,6 @@ class Sha256Hash private constructor(internal val bytes: ByteArray) : Comparable } } - /** 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) 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 index 2b4032ee4d..459ddb5b6f 100644 --- a/libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/PBKDF2SHA512.kt +++ b/libs/encryption/sha512/src/commonMain/kotlin/com/getcode/crypt/PBKDF2SHA512.kt @@ -1,5 +1,6 @@ package com.getcode.crypt +import kotlin.jvm.JvmStatic import org.kotlincrypto.macs.hmac.sha2.HmacSHA512 /* @@ -36,6 +37,7 @@ object PBKDF2SHA512 { * Derives a key of [dkLen] bytes from password [P] and salt [S] using [c] * PBKDF2 iterations with HMAC-SHA-512 as the pseudorandom function. */ + @JvmStatic 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" } From 7b1a2364e4bfec8eacea4003e031d44bb6df0b87 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 7 Aug 2026 16:20:05 -0400 Subject: [PATCH 2/2] ci: add cred-free compile-check job to catch KMP interop regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a compile-check job that runs :apps:flipcash:app:compileDebugSources before the flipcash-tests job. This task compiles all Kotlin/Java sources but stops before processDebugGoogleServices, so it requires no secrets. The missing gate is what allowed the KMP conversions in #1201 and #1202 to land on code/cash without surfacing the Java interop breaks (missing @JvmStatic, internal visibility) — those were only caught by compiling the full consumer graph, which CI never did without creds. --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f80bfb141..ca7b283a25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,38 @@ env: JAVA_VERSION: 17 jobs: + compile-check: + name: Compile check (no creds required) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Java env + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'corretto' + cache: 'gradle' + + - name: Gradle build cache + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches/build-cache-1 + .gradle/configuration-cache + key: gradle-build-cache-${{ hashFiles('**/*.gradle.kts', 'gradle.properties') }} + restore-keys: | + gradle-build-cache- + + # compileDebugSources runs all Kotlin/Java compilation tasks but stops before + # processDebugGoogleServices (which needs secrets). This catches KMP interop + # regressions (missing @JvmStatic, internal visibility leaking across modules) + # that only surface when consumers try to compile against the shared library. + - name: Compile app sources + run: ./gradlew :apps:flipcash:app:compileDebugSources --continue --no-daemon + flipcash-tests: name: Run Flipcash Tests runs-on: ubuntu-latest