Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 0 additions & 32 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,6 @@ 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
Expand Down
1 change: 1 addition & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ android.enableR8.fullMode=true
org.gradle.tooling.parallel=true

kotlin.native.ignoreDisabledTargets=true
kotlin.mpp.enableCInteropCommonization=true
2 changes: 2 additions & 0 deletions kmp/shared-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ kotlin {
export(project(":libs:encryption:sha256"))
export(project(":libs:encryption:sha512"))
export(project(":libs:encryption:hmac"))
export(project(":libs:encryption:ed25519"))
}
}

Expand All @@ -34,6 +35,7 @@ kotlin {
api(project(":libs:encryption:sha256"))
api(project(":libs:encryption:sha512"))
api(project(":libs:encryption:hmac"))
api(project(":libs:encryption:ed25519"))
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions libs/encryption/ed25519-native/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
plugins {
alias(libs.plugins.flipcash.android.library)
}

android {
namespace = "${Gradle.codeNamespace}.ed25519"
ndkVersion = "29.0.14206865"
defaultConfig {
externalNativeBuild {
cmake {
cppFlags += "-std=c++11"
}
}
}

externalNativeBuild {
cmake {
path = file("CMakeLists.txt")
}
}
}

dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(libs.bundles.kotlinx.serialization)

// Cross-platform test-vector gate (instrumented: JNI + android.util.Base64 need a device).
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
134 changes: 114 additions & 20 deletions libs/encryption/ed25519/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,30 +1,124 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget

plugins {
alias(libs.plugins.flipcash.android.library)
kotlin("multiplatform")
id("com.android.kotlin.multiplatform.library")
}

android {
namespace = "${Gradle.codeNamespace}.ed25519"
ndkVersion = "29.0.14206865"
defaultConfig {
externalNativeBuild {
cmake {
cppFlags += "-std=c++11"
}
}
}
// ── C source paths ────────────────────────────────────────────────────────────
// The ed25519 C sources live in the existing ed25519 JNI module.
val ed25519SrcDir = rootProject.file("libs/encryption/ed25519-native/libs/ed25519/src")
val ed25519CSources = fileTree(ed25519SrcDir) {
include("*.c")
// key_exchange.c is omitted — not part of our public API surface.
// seed.c is omitted — uses platform entropy; KMP API takes a caller-supplied seed.
exclude("key_exchange.c", "seed.c")
}

// ── Static-library compilation per Apple target ───────────────────────────────
//
// Kotlin/Native cinterop only generates Kotlin bindings from the header; it does
// not compile C sources for Apple targets. We produce a static archive
// (libored25519.a) for each target and tell the linker about it.
//
// Output: build/cinterop/<targetName>/libored25519.a

data class AppleTarget(val kotlinName: String, val sdk: String, val arch: String)

val appleTargetDefs = listOf(
AppleTarget("iosArm64", "iphoneos", "arm64"),
AppleTarget("iosSimulatorArm64", "iphonesimulator", "arm64"),
AppleTarget("iosX64", "iphonesimulator", "x86_64"),
)

externalNativeBuild {
cmake {
path = file("CMakeLists.txt")
appleTargetDefs.forEach { target ->
val outDir = layout.buildDirectory.dir("cinterop/${target.kotlinName}")
tasks.register("compileEd25519C_${target.kotlinName}", Exec::class) {
group = "cinterop"
description = "Compile ed25519 C sources into a static archive for ${target.kotlinName}"

inputs.files(ed25519CSources)
outputs.dir(outDir)

doFirst {
outDir.get().asFile.mkdirs()
}

// Compile each .c → .o then archive all .o into libored25519.a in one shell invocation.
commandLine("sh", "-c", buildString {
val compileLines = ed25519CSources.files.joinToString(" && ") { src ->
val obj = outDir.get().file(src.nameWithoutExtension + ".o").asFile.absolutePath
"xcrun -sdk ${target.sdk} clang -arch ${target.arch} -O2" +
" -c \"${src.absolutePath}\"" +
" -I\"${ed25519SrcDir.absolutePath}\"" +
" -o \"$obj\""
}
val objPaths = ed25519CSources.files.joinToString(" ") { src ->
"\"${outDir.get().file(src.nameWithoutExtension + ".o").asFile.absolutePath}\""
}
val libPath = outDir.get().file("libored25519.a").asFile.absolutePath
append(compileLines)
append(" && ar rcs \"$libPath\" $objPaths")
})
}
}

dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
implementation(libs.bundles.kotlinx.serialization)
kotlin {
android {
namespace = "com.getcode.encryption.ed25519"
compileSdk = 37
minSdk = 29
withHostTest {}
}

iosArm64()
iosSimulatorArm64()
iosX64()

// Cross-platform test-vector gate (instrumented: JNI + android.util.Base64 need a device).
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
// ── Cinterop + linker wiring for each Apple target ────────────────────────
targets.withType<KotlinNativeTarget>().configureEach {
val targetDef = appleTargetDefs.first { it.kotlinName == name }
val libDir = layout.buildDirectory.dir("cinterop/$name")
val compileTaskName = "compileEd25519C_$name"
val cinteropTaskName = "cinteropEd25519${name.replaceFirstChar { it.uppercaseChar() }}"

compilations["main"].cinterops.create("ed25519") {
definitionFile = file("cinterop/ed25519.def")
includeDirs(ed25519SrcDir)
}

// Both the cinterop binding task and the Kotlin compile task need the
// static archive to exist before linking.
tasks.matching { it.name == cinteropTaskName }.configureEach {
dependsOn(compileTaskName)
}
tasks.matching { it.name == "compileKotlin${name.replaceFirstChar { it.uppercaseChar() }}" }.configureEach {
dependsOn(compileTaskName)
}

binaries.configureEach {
linkerOpts("-L${libDir.get().asFile.absolutePath}", "-lored25519")
}
}

sourceSets {
commonMain {
// Ed25519Kmp expect object + KeyPair — no external deps.
}
androidMain {
dependencies {
// Delegate to the JNI module for NDK/CMake compilation. `api` (not
// `implementation`) so the JNI `com.getcode.ed25519.Ed25519` class is
// transitively re-exported to existing consumers of `:libs:encryption:ed25519`
// (e.g. :libs:encryption:utils) — keeps the rename consumer-transparent.
api(project(":libs:encryption:ed25519-native"))
}
}
commonTest {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.serialization.json)
}
}
}
}
2 changes: 2 additions & 0 deletions libs/encryption/ed25519/cinterop/ed25519.def
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
headers = ed25519.h
compilerOpts = -I../../libs/encryption/ed25519-native/libs/ed25519/src
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.getcode.ed25519kmp

actual fun readTestResource(name: String): String =
checkNotNull(Thread.currentThread().contextClassLoader?.getResourceAsStream(name)) {
"Resource '$name' not found on classpath"
}.bufferedReader().use { it.readText() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.getcode.ed25519kmp

import com.getcode.ed25519.Ed25519 as JniEd25519
import java.util.Base64

/**
* Android actual: delegates to the existing JNI [com.getcode.ed25519.Ed25519].
*
* The JNI layer encodes/decodes through Android's Base64 internally (Base64.DEFAULT
* which adds newlines). We replicate that encoding here using [java.util.Base64]
* (available Java 8+, works in both Android runtime and JVM host tests) with the
* MIME codec which also handles newlines on decode.
*/
actual object Ed25519Kmp {

actual fun createKeyPair(seed: ByteArray): KeyPair {
// Ed25519.java calls Base64.encodeToString(seed, Base64.DEFAULT) internally
// for createKeyPair(byte[]). We must replicate Base64.DEFAULT encoding
// (which wraps at 76 chars) so the JNI receives the expected format.
val seedB64 = Base64.getMimeEncoder().encodeToString(seed)
val jniPair = JniEd25519.createKeyPair(seedB64)
// JniPair.publicKey / privateKey are base64 strings (Base64.DEFAULT = MIME).
val publicKey = Base64.getMimeDecoder().decode(jniPair.publicKey)
val privateKey = Base64.getMimeDecoder().decode(jniPair.privateKey)
return KeyPair(publicKey = publicKey, privateKey = privateKey)
}

actual fun sign(
message: ByteArray,
publicKey: ByteArray,
privateKey: ByteArray,
): ByteArray = JniEd25519.Signature(message, privateKey, publicKey)
?: error("Ed25519.Signature returned null")

actual fun verify(
signature: ByteArray,
message: ByteArray,
publicKey: ByteArray,
): Boolean = JniEd25519.Verify(signature, message, publicKey)

actual fun onCurve(publicKey: ByteArray): Boolean =
JniEd25519.OnCurve(publicKey)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.getcode.ed25519kmp

import androidx.test.platform.app.InstrumentationRegistry

actual fun readTestResource(name: String): String =
InstrumentationRegistry.getInstrumentation().context.assets
.open(name).bufferedReader().use { it.readText() }
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.getcode.ed25519kmp

/**
* Cross-platform Ed25519 signatures (RFC 8032 / orlp implementation).
*
* All byte arrays are raw (not base64). Sizes:
* seed = 32 bytes
* publicKey = 32 bytes
* privateKey = 64 bytes (seed || public key, orlp convention)
* signature = 64 bytes
*/
expect object Ed25519Kmp {

/** Derives a (publicKey, privateKey) pair from a 32-byte seed. */
fun createKeyPair(seed: ByteArray): KeyPair

/**
* Signs [message] with [privateKey] (64-byte extended key) + [publicKey].
* Returns a 64-byte signature.
*/
fun sign(message: ByteArray, publicKey: ByteArray, privateKey: ByteArray): ByteArray

/**
* Verifies [signature] over [message] using [publicKey].
* Returns true iff the signature is valid.
*/
fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean

/** Returns true iff [publicKey] is a valid point on the Ed25519 curve. */
fun onCurve(publicKey: ByteArray): Boolean
}

/** Ed25519 key pair. */
data class KeyPair(val publicKey: ByteArray, val privateKey: ByteArray) {
override fun equals(other: Any?): Boolean =
other is KeyPair &&
publicKey.contentEquals(other.publicKey) &&
privateKey.contentEquals(other.privateKey)
override fun hashCode(): Int = 31 * publicKey.contentHashCode() + privateKey.contentHashCode()
}
Loading
Loading