diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca7b283a25..5f80bfb141 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/gradle.properties b/gradle.properties index 5b07082ba9..fc1b2b20e3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -50,3 +50,4 @@ android.enableR8.fullMode=true org.gradle.tooling.parallel=true kotlin.native.ignoreDisabledTargets=true +kotlin.mpp.enableCInteropCommonization=true diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts index 90ab11b4a4..d1ba88a669 100644 --- a/kmp/shared-core/build.gradle.kts +++ b/kmp/shared-core/build.gradle.kts @@ -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")) } } @@ -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")) } } } diff --git a/libs/encryption/ed25519/.gitignore b/libs/encryption/ed25519-native/.gitignore similarity index 100% rename from libs/encryption/ed25519/.gitignore rename to libs/encryption/ed25519-native/.gitignore diff --git a/libs/encryption/ed25519/CMakeLists.txt b/libs/encryption/ed25519-native/CMakeLists.txt similarity index 100% rename from libs/encryption/ed25519/CMakeLists.txt rename to libs/encryption/ed25519-native/CMakeLists.txt diff --git a/libs/encryption/ed25519-native/build.gradle.kts b/libs/encryption/ed25519-native/build.gradle.kts new file mode 100644 index 0000000000..8514da255a --- /dev/null +++ b/libs/encryption/ed25519-native/build.gradle.kts @@ -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) +} diff --git a/libs/encryption/ed25519/libs/base64/CMakeLists.txt b/libs/encryption/ed25519-native/libs/base64/CMakeLists.txt similarity index 100% rename from libs/encryption/ed25519/libs/base64/CMakeLists.txt rename to libs/encryption/ed25519-native/libs/base64/CMakeLists.txt diff --git a/libs/encryption/ed25519/libs/base64/base64.cpp b/libs/encryption/ed25519-native/libs/base64/base64.cpp similarity index 100% rename from libs/encryption/ed25519/libs/base64/base64.cpp rename to libs/encryption/ed25519-native/libs/base64/base64.cpp diff --git a/libs/encryption/ed25519/libs/base64/base64.hpp b/libs/encryption/ed25519-native/libs/base64/base64.hpp similarity index 100% rename from libs/encryption/ed25519/libs/base64/base64.hpp rename to libs/encryption/ed25519-native/libs/base64/base64.hpp diff --git a/libs/encryption/ed25519/libs/ed25519/CMakeLists.txt b/libs/encryption/ed25519-native/libs/ed25519/CMakeLists.txt similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/CMakeLists.txt rename to libs/encryption/ed25519-native/libs/ed25519/CMakeLists.txt diff --git a/libs/encryption/ed25519/libs/ed25519/license.txt b/libs/encryption/ed25519-native/libs/ed25519/license.txt similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/license.txt rename to libs/encryption/ed25519-native/libs/ed25519/license.txt diff --git a/libs/encryption/ed25519/libs/ed25519/readme.md b/libs/encryption/ed25519-native/libs/ed25519/readme.md similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/readme.md rename to libs/encryption/ed25519-native/libs/ed25519/readme.md diff --git a/libs/encryption/ed25519/libs/ed25519/src/add_scalar.c b/libs/encryption/ed25519-native/libs/ed25519/src/add_scalar.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/add_scalar.c rename to libs/encryption/ed25519-native/libs/ed25519/src/add_scalar.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/ed25519.h b/libs/encryption/ed25519-native/libs/ed25519/src/ed25519.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/ed25519.h rename to libs/encryption/ed25519-native/libs/ed25519/src/ed25519.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/fe.c b/libs/encryption/ed25519-native/libs/ed25519/src/fe.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/fe.c rename to libs/encryption/ed25519-native/libs/ed25519/src/fe.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/fe.h b/libs/encryption/ed25519-native/libs/ed25519/src/fe.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/fe.h rename to libs/encryption/ed25519-native/libs/ed25519/src/fe.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/fixedint.h b/libs/encryption/ed25519-native/libs/ed25519/src/fixedint.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/fixedint.h rename to libs/encryption/ed25519-native/libs/ed25519/src/fixedint.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/ge.c b/libs/encryption/ed25519-native/libs/ed25519/src/ge.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/ge.c rename to libs/encryption/ed25519-native/libs/ed25519/src/ge.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/ge.h b/libs/encryption/ed25519-native/libs/ed25519/src/ge.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/ge.h rename to libs/encryption/ed25519-native/libs/ed25519/src/ge.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/key_exchange.c b/libs/encryption/ed25519-native/libs/ed25519/src/key_exchange.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/key_exchange.c rename to libs/encryption/ed25519-native/libs/ed25519/src/key_exchange.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/keypair.c b/libs/encryption/ed25519-native/libs/ed25519/src/keypair.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/keypair.c rename to libs/encryption/ed25519-native/libs/ed25519/src/keypair.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/precomp_data.h b/libs/encryption/ed25519-native/libs/ed25519/src/precomp_data.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/precomp_data.h rename to libs/encryption/ed25519-native/libs/ed25519/src/precomp_data.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/sc.c b/libs/encryption/ed25519-native/libs/ed25519/src/sc.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sc.c rename to libs/encryption/ed25519-native/libs/ed25519/src/sc.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/sc.h b/libs/encryption/ed25519-native/libs/ed25519/src/sc.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sc.h rename to libs/encryption/ed25519-native/libs/ed25519/src/sc.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/seed.c b/libs/encryption/ed25519-native/libs/ed25519/src/seed.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/seed.c rename to libs/encryption/ed25519-native/libs/ed25519/src/seed.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/sha3.c b/libs/encryption/ed25519-native/libs/ed25519/src/sha3.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sha3.c rename to libs/encryption/ed25519-native/libs/ed25519/src/sha3.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/sha3.h b/libs/encryption/ed25519-native/libs/ed25519/src/sha3.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sha3.h rename to libs/encryption/ed25519-native/libs/ed25519/src/sha3.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/sha512.c b/libs/encryption/ed25519-native/libs/ed25519/src/sha512.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sha512.c rename to libs/encryption/ed25519-native/libs/ed25519/src/sha512.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/sha512.h b/libs/encryption/ed25519-native/libs/ed25519/src/sha512.h similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sha512.h rename to libs/encryption/ed25519-native/libs/ed25519/src/sha512.h diff --git a/libs/encryption/ed25519/libs/ed25519/src/sign.c b/libs/encryption/ed25519-native/libs/ed25519/src/sign.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/sign.c rename to libs/encryption/ed25519-native/libs/ed25519/src/sign.c diff --git a/libs/encryption/ed25519/libs/ed25519/src/verify.c b/libs/encryption/ed25519-native/libs/ed25519/src/verify.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/src/verify.c rename to libs/encryption/ed25519-native/libs/ed25519/src/verify.c diff --git a/libs/encryption/ed25519/libs/ed25519/test.c b/libs/encryption/ed25519-native/libs/ed25519/test.c similarity index 100% rename from libs/encryption/ed25519/libs/ed25519/test.c rename to libs/encryption/ed25519-native/libs/ed25519/test.c diff --git a/libs/encryption/ed25519/src/androidTest/assets/ed25519.json b/libs/encryption/ed25519-native/src/androidTest/assets/ed25519.json similarity index 100% rename from libs/encryption/ed25519/src/androidTest/assets/ed25519.json rename to libs/encryption/ed25519-native/src/androidTest/assets/ed25519.json diff --git a/libs/encryption/ed25519/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt b/libs/encryption/ed25519-native/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt similarity index 100% rename from libs/encryption/ed25519/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt rename to libs/encryption/ed25519-native/src/androidTest/java/com/getcode/ed25519/Ed25519VectorTest.kt diff --git a/libs/encryption/ed25519/src/main/AndroidManifest.xml b/libs/encryption/ed25519-native/src/main/AndroidManifest.xml similarity index 100% rename from libs/encryption/ed25519/src/main/AndroidManifest.xml rename to libs/encryption/ed25519-native/src/main/AndroidManifest.xml diff --git a/libs/encryption/ed25519/src/main/cpp/native-lib.cpp b/libs/encryption/ed25519-native/src/main/cpp/native-lib.cpp similarity index 100% rename from libs/encryption/ed25519/src/main/cpp/native-lib.cpp rename to libs/encryption/ed25519-native/src/main/cpp/native-lib.cpp diff --git a/libs/encryption/ed25519/src/main/java/com/getcode/ed25519/Ed25519.java b/libs/encryption/ed25519-native/src/main/java/com/getcode/ed25519/Ed25519.java similarity index 100% rename from libs/encryption/ed25519/src/main/java/com/getcode/ed25519/Ed25519.java rename to libs/encryption/ed25519-native/src/main/java/com/getcode/ed25519/Ed25519.java diff --git a/libs/encryption/ed25519/src/main/java/com/getcode/utils/serializer/KeyPairAsStringSerializer.kt b/libs/encryption/ed25519-native/src/main/java/com/getcode/utils/serializer/KeyPairAsStringSerializer.kt similarity index 100% rename from libs/encryption/ed25519/src/main/java/com/getcode/utils/serializer/KeyPairAsStringSerializer.kt rename to libs/encryption/ed25519-native/src/main/java/com/getcode/utils/serializer/KeyPairAsStringSerializer.kt diff --git a/libs/encryption/ed25519/build.gradle.kts b/libs/encryption/ed25519/build.gradle.kts index 8514da255a..d724516e4e 100644 --- a/libs/encryption/ed25519/build.gradle.kts +++ b/libs/encryption/ed25519/build.gradle.kts @@ -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//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().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) + } + } + } } diff --git a/libs/encryption/ed25519/cinterop/ed25519.def b/libs/encryption/ed25519/cinterop/ed25519.def new file mode 100644 index 0000000000..fddbb129f8 --- /dev/null +++ b/libs/encryption/ed25519/cinterop/ed25519.def @@ -0,0 +1,2 @@ +headers = ed25519.h +compilerOpts = -I../../libs/encryption/ed25519-native/libs/ed25519/src diff --git a/libs/encryption/ed25519/src/androidHostTest/jniLibs/libed25519.dylib b/libs/encryption/ed25519/src/androidHostTest/jniLibs/libed25519.dylib new file mode 100755 index 0000000000..09cc85ffa9 Binary files /dev/null and b/libs/encryption/ed25519/src/androidHostTest/jniLibs/libed25519.dylib differ diff --git a/libs/encryption/ed25519/src/androidHostTest/jniLibs/libnative-lib.dylib b/libs/encryption/ed25519/src/androidHostTest/jniLibs/libnative-lib.dylib new file mode 100755 index 0000000000..b12ffa424b Binary files /dev/null and b/libs/encryption/ed25519/src/androidHostTest/jniLibs/libnative-lib.dylib differ diff --git a/libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt b/libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt new file mode 100644 index 0000000000..7f675d002b --- /dev/null +++ b/libs/encryption/ed25519/src/androidHostTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt @@ -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() } diff --git a/libs/encryption/ed25519/src/androidMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.android.kt b/libs/encryption/ed25519/src/androidMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.android.kt new file mode 100644 index 0000000000..cd991420f9 --- /dev/null +++ b/libs/encryption/ed25519/src/androidMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.android.kt @@ -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) +} diff --git a/libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt b/libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt new file mode 100644 index 0000000000..de9a6d0214 --- /dev/null +++ b/libs/encryption/ed25519/src/androidTest/kotlin/com/getcode/ed25519kmp/TestResources.android.kt @@ -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() } diff --git a/libs/encryption/ed25519/src/commonMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.kt b/libs/encryption/ed25519/src/commonMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.kt new file mode 100644 index 0000000000..dc4ee8ae35 --- /dev/null +++ b/libs/encryption/ed25519/src/commonMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.kt @@ -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() +} diff --git a/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/Ed25519VectorTest.kt b/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/Ed25519VectorTest.kt new file mode 100644 index 0000000000..723706b0df --- /dev/null +++ b/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/Ed25519VectorTest.kt @@ -0,0 +1,105 @@ +package com.getcode.ed25519kmp + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * GATE: Ed25519 must reproduce RFC 8032 vectors on BOTH Android (JVM host) and iOS (native). + * Fixture: src/commonTest/resources/ed25519.json (canonical source: test-vectors/ed25519.json). + * + * Vector format: + * seed (32-byte hex) + message (hex, may be empty) + * → publicKey (32-byte hex) + signature (64-byte hex) + */ +class Ed25519VectorTest { + + @Test + fun ed25519_createKeyPair_matches_canonical_vectors() { + val vectors = loadVectors() + assertTrue(vectors.isNotEmpty(), "no vectors loaded from ed25519.json") + + for (v in vectors) { + val pair = Ed25519Kmp.createKeyPair(v.seed) + assertEquals( + v.publicKey.toHexString(), pair.publicKey.toHexString(), + "publicKey mismatch for '${v.name}'" + ) + } + } + + @Test + fun ed25519_sign_matches_canonical_vectors() { + val vectors = loadVectors() + for (v in vectors) { + val pair = Ed25519Kmp.createKeyPair(v.seed) + val sig = Ed25519Kmp.sign(v.message, pair.publicKey, pair.privateKey) + assertEquals( + v.signature.toHexString(), sig.toHexString(), + "signature mismatch for '${v.name}'" + ) + } + } + + @Test + fun ed25519_verify_accepts_canonical_signatures() { + val vectors = loadVectors() + for (v in vectors) { + val pair = Ed25519Kmp.createKeyPair(v.seed) + val sig = Ed25519Kmp.sign(v.message, pair.publicKey, pair.privateKey) + assertTrue( + Ed25519Kmp.verify(sig, v.message, pair.publicKey), + "verify returned false for '${v.name}'" + ) + } + } + + @Test + fun ed25519_onCurve_true_for_canonical_public_keys() { + val vectors = loadVectors() + for (v in vectors) { + assertTrue( + Ed25519Kmp.onCurve(v.publicKey), + "onCurve returned false for '${v.name}'" + ) + } + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private data class Vector( + val name: String, + val seed: ByteArray, + val message: ByteArray, + val publicKey: ByteArray, + val signature: ByteArray, + ) + + private fun loadVectors(): List { + val text = readTestResource("ed25519.json") + val root = Json.parseToJsonElement(text).jsonObject + return root["vectors"]!!.jsonArray.map { el -> + val o = el.jsonObject + Vector( + name = o["name"]!!.jsonPrimitive.content, + seed = o["seed"]!!.jsonPrimitive.content.hexToBytes(), + message = o["message"]!!.jsonPrimitive.content.hexToBytes(), + publicKey = o["publicKey"]!!.jsonPrimitive.content.hexToBytes(), + signature = o["signature"]!!.jsonPrimitive.content.hexToBytes(), + ) + } + } + + private fun String.hexToBytes(): ByteArray = + if (isEmpty()) ByteArray(0) + else ByteArray(length / 2) { i -> + ((this[i * 2].digitToInt(16) shl 4) or this[i * 2 + 1].digitToInt(16)).toByte() + } + + private fun ByteArray.toHexString(): String = + joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} diff --git a/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt b/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt new file mode 100644 index 0000000000..625819838c --- /dev/null +++ b/libs/encryption/ed25519/src/commonTest/kotlin/com/getcode/ed25519kmp/TestResources.kt @@ -0,0 +1,4 @@ +package com.getcode.ed25519kmp + +/** Reads a test resource file by name from `src/commonTest/resources/`. */ +expect fun readTestResource(name: String): String diff --git a/libs/encryption/ed25519/src/commonTest/resources/ed25519.json b/libs/encryption/ed25519/src/commonTest/resources/ed25519.json new file mode 100644 index 0000000000..0f3ec59714 --- /dev/null +++ b/libs/encryption/ed25519/src/commonTest/resources/ed25519.json @@ -0,0 +1,49 @@ +{ + "algorithm": "ed25519", + "spec": "RFC 8032 (SHA-512). seed=32-byte private seed; publicKey/signature are standard outputs.", + "note": "Cross-platform parity gate: both apps must reproduce publicKey and signature for each seed/message.", + "vectors": [ + { + "name": "rfc8032-test1", + "seed": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "message": "", + "publicKey": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", + "signature": "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b" + }, + { + "name": "rfc8032-test2", + "seed": "4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6f8", + "message": "72", + "publicKey": "f7cbf1b83a8ff48bd6f88cdf132b1fee4a1ffc436741f1e068d2d9c29a9308ae", + "signature": "4b5737a671f7b7f4d50848fc87277460cd65142d5752c17a2b7b10390a8803c48a52f04f37ca575fe456e632bff18de8bc32a38dec5535a874a850d18b4fe300" + }, + { + "name": "rfc8032-test3", + "seed": "c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "message": "af82", + "publicKey": "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", + "signature": "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a" + }, + { + "name": "zero-seed-empty", + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "message": "", + "publicKey": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29", + "signature": "8f895b3cafe2c9506039d0e2a66382568004674fe8d237785092e40d6aaf483e4fc60168705f31f101596138ce21aa357c0d32a064f423dc3ee4aa3abf53f803" + }, + { + "name": "zero-seed-32b", + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "message": "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + "publicKey": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29", + "signature": "b715b42bcdf6a3755d83f500103441557410920c276efb3102752f36e301ccdec2e5015ebdd6595a1844518a69b85f156db8ed9792d85f483f5c779ae2b90b0f" + }, + { + "name": "seed-ff-tx", + "seed": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "message": "0100000000000000dededededededededededededededededededededededededededededededededededededededededededededededededededededededededededededededede", + "publicKey": "76a1592044a6e4f511265bca73a604d90b0529d1df602be30a19a9257660d1f5", + "signature": "22cb95f107e77716d4084c004543abc3dfa7562fee71d3af63c844a357c75e0277a29d275f10a65c57c93c0b1d60a46cb1c49f045a2b82fbc03d113e1768c40a" + } + ] +} diff --git a/libs/encryption/ed25519/src/iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt b/libs/encryption/ed25519/src/iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt new file mode 100644 index 0000000000..773287e467 --- /dev/null +++ b/libs/encryption/ed25519/src/iosMain/kotlin/com/getcode/ed25519kmp/Ed25519Kmp.ios.kt @@ -0,0 +1,75 @@ +package com.getcode.ed25519kmp + +import ed25519.ed25519_create_keypair +import ed25519.ed25519_on_curve +import ed25519.ed25519_sign +import ed25519.ed25519_verify +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.readBytes +import kotlinx.cinterop.toCValues +import kotlinx.cinterop.UByteVar + +/** + * iOS actual: calls the vendored orlp/ed25519 C library directly via Kotlin/Native cinterop. + * + * Memory safety: + * - All byte arrays are pinned or copied into C-managed memory inside [memScoped]. + * - Output buffers are stack-allocated inside [memScoped] then copied to [ByteArray]. + * + * Sizes (from ed25519.h): + * publicKey = 32 bytes + * privateKey = 64 bytes (seed || public key, orlp convention) + * signature = 64 bytes + */ +@OptIn(ExperimentalForeignApi::class) +actual object Ed25519Kmp { + + actual fun createKeyPair(seed: ByteArray): KeyPair = memScoped { + require(seed.size == 32) { "seed must be 32 bytes, got ${seed.size}" } + val pubKey = allocArray(32) + val privKey = allocArray(64) + val seedPtr = seed.toUByteArray().toCValues().ptr + ed25519_create_keypair(pubKey, privKey, seedPtr) + KeyPair( + publicKey = pubKey.readBytes(32), + privateKey = privKey.readBytes(64), + ) + } + + actual fun sign( + message: ByteArray, + publicKey: ByteArray, + privateKey: ByteArray, + ): ByteArray = memScoped { + require(publicKey.size == 32) { "publicKey must be 32 bytes" } + require(privateKey.size == 64) { "privateKey must be 64 bytes" } + val sigBuf = allocArray(64) + val msgPtr = message.toUByteArray().toCValues().ptr + val pubPtr = publicKey.toUByteArray().toCValues().ptr + val privPtr = privateKey.toUByteArray().toCValues().ptr + ed25519_sign(sigBuf, msgPtr, message.size.convert(), pubPtr, privPtr) + sigBuf.readBytes(64) + } + + actual fun verify( + signature: ByteArray, + message: ByteArray, + publicKey: ByteArray, + ): Boolean = memScoped { + require(signature.size == 64) { "signature must be 64 bytes" } + require(publicKey.size == 32) { "publicKey must be 32 bytes" } + val sigPtr = signature.toUByteArray().toCValues().ptr + val msgPtr = message.toUByteArray().toCValues().ptr + val pubPtr = publicKey.toUByteArray().toCValues().ptr + ed25519_verify(sigPtr, msgPtr, message.size.convert(), pubPtr) != 0 + } + + actual fun onCurve(publicKey: ByteArray): Boolean = memScoped { + require(publicKey.size == 32) { "publicKey must be 32 bytes" } + val pubPtr = publicKey.toUByteArray().toCValues().ptr + ed25519_on_curve(pubPtr) != 0 + } +} diff --git a/libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt b/libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt new file mode 100644 index 0000000000..f8cb8422dc --- /dev/null +++ b/libs/encryption/ed25519/src/iosTest/kotlin/com/getcode/ed25519kmp/TestResources.ios.kt @@ -0,0 +1,17 @@ +package com.getcode.ed25519kmp + +import platform.Foundation.NSBundle +import platform.Foundation.NSString +import platform.Foundation.NSUTF8StringEncoding +import platform.Foundation.stringWithContentsOfFile + +actual fun readTestResource(name: String): String { + val nameWithoutExt = name.substringBeforeLast(".") + val ext = name.substringAfterLast(".", "") + val path = checkNotNull( + NSBundle.mainBundle.pathForResource(nameWithoutExt, ext) + ) { "Resource '$name' not found in bundle" } + return checkNotNull( + NSString.stringWithContentsOfFile(path, NSUTF8StringEncoding, null) + ) { "Failed to read resource '$name' at $path" } as String +} 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 deleted file mode 100644 index b4ae23c579..0000000000 --- a/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicCode.java +++ /dev/null @@ -1,255 +0,0 @@ -package com.getcode.crypt; - -/* - * Copyright 2013 Ken Sedgwick - * 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 android.content.res.Resources; - -import com.getcode.encryption.mnemonic.R; -import com.getcode.utils.Utils; -import com.google.common.base.Stopwatch; - -import java.io.BufferedReader; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import static com.google.common.base.Preconditions.checkNotNull; - -import timber.log.Timber; - -/** - * A MnemonicCode object may be used to convert between binary seed values and - * lists of words per the BIP 39 - * specification - */ - -public class MnemonicCode { - public static final String TAG = "MnemonicCode"; - - public ArrayList wordList; - - private static final String BIP39_ENGLISH_RESOURCE_NAME = "english.txt"; - private static final String BIP39_ENGLISH_SHA256 = "ad90bf3beb7b0eb7e5acd74727dc0da96e0a280a258354e7293fb7e211ac03db"; - - /** UNIX time for when the BIP39 standard was finalised. This can be used as a default seed birthday. */ - public static long BIP39_STANDARDISATION_TIME_SECS = 1381276800; - - private static final int PBKDF2_ROUNDS = 2048; - - public static MnemonicCode INSTANCE; - - static { - try { - INSTANCE = new MnemonicCode(null); - } catch (FileNotFoundException e) { - // We expect failure on Android. The developer has to set INSTANCE themselves. - if (!Utils.isAndroidRuntime()) - Timber.e("Could not find word list"); - } catch (IOException e) { - Timber.e("Failed to load word list"); - } - } - - /** Initialise from the included word list. Won't work on Android. */ - public MnemonicCode(Resources resources) throws IOException { - this(openDefaultWords(resources), BIP39_ENGLISH_SHA256); - } - - private static InputStream openDefaultWords(Resources resources) throws IOException { - if (resources == null) return null; - InputStream stream = resources.openRawResource(R.raw.english); - if (stream == null) - throw new FileNotFoundException(BIP39_ENGLISH_RESOURCE_NAME); - return stream; - } - - /** - * Creates an MnemonicCode object, initializing with words read from the supplied input stream. If a wordListDigest - * is supplied the digest of the words will be checked. - */ - public MnemonicCode(InputStream wordstream, String wordListDigest) throws IOException, IllegalArgumentException { - if (wordstream == null) return; - BufferedReader br = new BufferedReader(new InputStreamReader(wordstream, StandardCharsets.UTF_8)); - this.wordList = new ArrayList<>(2048); - 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()); - this.wordList.add(word); - } - br.close(); - - if (this.wordList.size() != 2048) - throw new IllegalArgumentException("input stream did not contain 2048 words"); - - // If a wordListDigest is supplied check to make sure it matches. - if (wordListDigest != null) { - byte[] digest = md.digest(); - String hexdigest = Utils.HEX.encode(digest); - if (!hexdigest.equals(wordListDigest)) - throw new IllegalArgumentException("wordlist digest mismatch"); - } - } - - /** - * Gets the word list this code uses. - */ - public List getWordList() { - return wordList; - } - - /** - * Convert mnemonic word list to seed. - */ - public static byte[] toSeed(List words, String passphrase) { - checkNotNull(passphrase, "A null passphrase is not allowed."); - - // To create binary seed from mnemonic, we use PBKDF2 function - // with mnemonic sentence (in UTF-8) used as a password and - // string "mnemonic" + passphrase (again in UTF-8) used as a - // salt. Iteration count is set to 4096 and HMAC-SHA512 is - // used as a pseudo-random function. Desired length of the - // derived key is 512 bits (= 64 bytes). - // - String pass = Utils.spaceJoin(words); - String salt = "mnemonic" + passphrase; - - final Stopwatch watch = Stopwatch.createStarted(); - byte[] seed = PBKDF2SHA512.derive(pass, salt, PBKDF2_ROUNDS, 64); - watch.stop(); - Timber.i("PBKDF2 took {} %s", watch); - return seed; - } - - /** - * Convert mnemonic word list to original entropy value. - */ - public byte[] toEntropy(List words) throws MnemonicException.MnemonicLengthException, MnemonicException.MnemonicWordException, MnemonicException.MnemonicChecksumException { - if (words.size() % 3 > 0) - throw new MnemonicException.MnemonicLengthException("Word list size must be multiple of three words."); - - if (words.isEmpty()) - throw new MnemonicException.MnemonicLengthException("Word list is empty."); - - // Look up all the words in the list and construct the - // concatenation of the original entropy and the checksum. - // - int concatLenBits = words.size() * 11; - boolean[] concatBits = new boolean[concatLenBits]; - int wordindex = 0; - for (String word : words) { - // Find the words index in the wordlist. - int ndx = Collections.binarySearch(this.wordList, word); - if (ndx < 0) - throw new MnemonicException.MnemonicWordException(word); - - // Set the next 11 bits to the value of the index. - for (int ii = 0; ii < 11; ++ii) - concatBits[(wordindex * 11) + ii] = (ndx & (1 << (10 - ii))) != 0; - ++wordindex; - } - - int checksumLengthBits = concatLenBits / 33; - int entropyLengthBits = concatLenBits - checksumLengthBits; - - // Extract original entropy as bytes. - byte[] entropy = new byte[entropyLengthBits / 8]; - for (int ii = 0; ii < entropy.length; ++ii) - for (int jj = 0; jj < 8; ++jj) - if (concatBits[(ii * 8) + jj]) - entropy[ii] |= (byte) (1 << (7 - jj)); - - // Take the digest of the entropy. - byte[] hash = Sha256Hash.hash(entropy); - boolean[] hashBits = bytesToBits(hash); - - // Check all the checksum bits. - for (int i = 0; i < checksumLengthBits; ++i) - if (concatBits[entropyLengthBits + i] != hashBits[i]) - throw new MnemonicException.MnemonicChecksumException(); - - return entropy; - } - - /** - * Convert entropy data to mnemonic word list. - */ - public List toMnemonic(byte[] entropy) throws MnemonicException.MnemonicLengthException { - if (entropy.length % 4 > 0) - throw new MnemonicException.MnemonicLengthException("Entropy length not multiple of 32 bits."); - - if (entropy.length == 0) - throw new MnemonicException.MnemonicLengthException("Entropy is empty."); - - // We take initial entropy of ENT bits and compute its - // checksum by taking first ENT / 32 bits of its SHA256 hash. - - byte[] hash = Sha256Hash.hash(entropy); - boolean[] hashBits = bytesToBits(hash); - - boolean[] entropyBits = bytesToBits(entropy); - int checksumLengthBits = entropyBits.length / 32; - - // We append these bits to the end of the initial entropy. - boolean[] concatBits = new boolean[entropyBits.length + checksumLengthBits]; - System.arraycopy(entropyBits, 0, concatBits, 0, entropyBits.length); - System.arraycopy(hashBits, 0, concatBits, entropyBits.length, checksumLengthBits); - - // Next we take these concatenated bits and split them into - // groups of 11 bits. Each group encodes number from 0-2047 - // which is a position in a wordlist. We convert numbers into - // words and use joined words as mnemonic sentence. - - ArrayList words = new ArrayList<>(); - int nwords = concatBits.length / 11; - for (int i = 0; i < nwords; ++i) { - int index = 0; - for (int j = 0; j < 11; ++j) { - index <<= 1; - if (concatBits[(i * 11) + j]) - index |= 0x1; - } - words.add(this.wordList.get(index)); - } - - return words; - } - - /** - * Check to see if a mnemonic word list is valid. - */ - public void check(List words) throws MnemonicException { - toEntropy(words); - } - - private static boolean[] bytesToBits(byte[] data) { - boolean[] bits = new boolean[data.length * 8]; - for (int i = 0; i < data.length; ++i) - for (int j = 0; j < 8; ++j) - bits[(i * 8) + j] = (data[i] & (1 << (7 - j))) != 0; - return bits; - } -} diff --git a/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicException.java b/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicException.java deleted file mode 100644 index 37b6ab7e2f..0000000000 --- a/libs/encryption/mnemonic/src/main/java/com/getcode/crypt/MnemonicException.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.getcode.crypt; - -/* - * Copyright 2013 Ken Sedgwick - * - * 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. - */ - - -/** - * Exceptions thrown by the MnemonicCode module. - */ -@SuppressWarnings("serial") -public class MnemonicException extends Exception { - public MnemonicException() { - super(); - } - - public MnemonicException(String msg) { - super(msg); - } - - /** - * Thrown when an argument to MnemonicCode is the wrong length. - */ - public static class MnemonicLengthException extends MnemonicException { - public MnemonicLengthException(String msg) { - super(msg); - } - } - - /** - * Thrown when a list of MnemonicCode words fails the checksum check. - */ - public static class MnemonicChecksumException extends MnemonicException { - public MnemonicChecksumException() { - super(); - } - } - - /** - * Thrown when a word is encountered which is not in the MnemonicCode's word list. - */ - public static class MnemonicWordException extends MnemonicException { - /** Contains the word that was not found in the word list. */ - public final String badWord; - - public MnemonicWordException(String badWord) { - super(); - this.badWord = badWord; - } - } -} diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt new file mode 100644 index 0000000000..3a305e20da --- /dev/null +++ b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicCode.kt @@ -0,0 +1,237 @@ +package com.getcode.crypt + +/* + * Copyright 2013 Ken Sedgwick + * 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 android.content.res.Resources +import com.getcode.encryption.mnemonic.R +import com.getcode.utils.Utils +import com.google.common.base.Stopwatch +import timber.log.Timber +import java.io.BufferedReader +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.security.MessageDigest + +/** + * Converts between binary seed values and lists of words per the + * [BIP 39 specification](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki). + */ +class MnemonicCode { + + var wordList: ArrayList = ArrayList() + private set + + /** + * Initialises from the included word list (requires an [android.content.res.Resources] + * instance to open the raw resource; not usable on bare JVM). + */ + @Throws(IOException::class) + constructor(resources: Resources?) { + val stream = openDefaultWords(resources) ?: return + init(stream, BIP39_ENGLISH_SHA256) + } + + /** + * Initialises with words read from [wordstream]. If [wordListDigest] is non-null the + * SHA-256 hex digest of the word list is verified against it. + */ + @Throws(IOException::class, IllegalArgumentException::class) + constructor(wordstream: InputStream?, wordListDigest: String?) { + wordstream ?: return + init(wordstream, wordListDigest) + } + + @Throws(IOException::class) + private fun init(wordstream: InputStream, wordListDigest: String?) { + val br = BufferedReader(InputStreamReader(wordstream, Charsets.UTF_8)) + val list = ArrayList(2048) + val md: MessageDigest = MessageDigest.getInstance("SHA-256") + br.forEachLine { word -> + md.update(word.toByteArray()) + list.add(word) + } + br.close() + + if (list.size != 2048) throw IllegalArgumentException("input stream did not contain 2048 words") + + if (wordListDigest != null) { + val hexDigest = md.digest().toHexString() + if (hexDigest != wordListDigest) throw IllegalArgumentException("wordlist digest mismatch") + } + + wordList = list + } + + /** Returns the word list this instance uses. */ + fun getWordList(): List = wordList + + /** + * Converts mnemonic word list to original entropy bytes. + * + * @throws MnemonicException.MnemonicLengthException if the word count is not a multiple of 3 or the list is empty + * @throws MnemonicException.MnemonicWordException if a word is not in the word list + * @throws MnemonicException.MnemonicChecksumException if the checksum does not match + */ + @Throws(MnemonicException::class) + fun toEntropy(words: List): ByteArray { + if (words.size % 3 != 0) + throw MnemonicException.MnemonicLengthException("Word list size must be multiple of three words.") + if (words.isEmpty()) + throw MnemonicException.MnemonicLengthException("Word list is empty.") + + val concatLenBits = words.size * 11 + val concatBits = BooleanArray(concatLenBits) + var wordIndex = 0 + for (word in words) { + val ndx = wordList.binarySearch(word) + if (ndx < 0) throw MnemonicException.MnemonicWordException(word) + for (ii in 0 until 11) + concatBits[wordIndex * 11 + ii] = (ndx and (1 shl (10 - ii))) != 0 + wordIndex++ + } + + val checksumLengthBits = concatLenBits / 33 + val entropyLengthBits = concatLenBits - checksumLengthBits + + val entropy = ByteArray(entropyLengthBits / 8) + for (ii in entropy.indices) + for (jj in 0 until 8) + if (concatBits[ii * 8 + jj]) + entropy[ii] = (entropy[ii].toInt() or (1 shl (7 - jj))).toByte() + + val hash = Sha256Hash.hash(entropy) + val hashBits = bytesToBits(hash) + + for (i in 0 until checksumLengthBits) + if (concatBits[entropyLengthBits + i] != hashBits[i]) + throw MnemonicException.MnemonicChecksumException() + + return entropy + } + + /** + * Converts entropy bytes to a mnemonic word list. + * + * @throws MnemonicException.MnemonicLengthException if the entropy length is not a multiple of 4 or is empty + */ + @Throws(MnemonicException.MnemonicLengthException::class) + fun toMnemonic(entropy: ByteArray): List { + if (entropy.size % 4 != 0) + throw MnemonicException.MnemonicLengthException("Entropy length not multiple of 32 bits.") + if (entropy.isEmpty()) + throw MnemonicException.MnemonicLengthException("Entropy is empty.") + + val hash = Sha256Hash.hash(entropy) + val hashBits = bytesToBits(hash) + val entropyBits = bytesToBits(entropy) + val checksumLengthBits = entropyBits.size / 32 + + val concatBits = BooleanArray(entropyBits.size + checksumLengthBits) + entropyBits.copyInto(concatBits, destinationOffset = 0) + hashBits.copyInto(concatBits, destinationOffset = entropyBits.size, endIndex = checksumLengthBits) + + val words = ArrayList() + val nwords = concatBits.size / 11 + for (i in 0 until nwords) { + var index = 0 + for (j in 0 until 11) { + index = index shl 1 + if (concatBits[i * 11 + j]) index = index or 0x1 + } + words.add(wordList[index]) + } + return words + } + + /** + * Checks whether a mnemonic word list is valid. + * + * @throws MnemonicException if validation fails + */ + @Throws(MnemonicException::class) + fun check(words: List) { + toEntropy(words) + } + + companion object { + const val TAG: String = "MnemonicCode" + + private const val BIP39_ENGLISH_RESOURCE_NAME = "english.txt" + private const val BIP39_ENGLISH_SHA256 = "ad90bf3beb7b0eb7e5acd74727dc0da96e0a280a258354e7293fb7e211ac03db" + + /** UNIX time for when the BIP39 standard was finalised. Use as a default seed birthday. */ + @JvmField + var BIP39_STANDARDISATION_TIME_SECS: Long = 1381276800L + + private const val PBKDF2_ROUNDS = 2048 + + /** Shared instance (null until set, e.g. via [MnemonicCache.init]). */ + @JvmField + var INSTANCE: MnemonicCode? = null + + init { + try { + INSTANCE = MnemonicCode(null as Resources?) + } catch (e: FileNotFoundException) { + if (!Utils.isAndroidRuntime()) Timber.e("Could not find word list") + } catch (e: IOException) { + Timber.e("Failed to load word list") + } + } + + /** Converts a mnemonic word list to a 64-byte PBKDF2-SHA512 seed. */ + @JvmStatic + fun toSeed(words: List, passphrase: String): ByteArray { + requireNotNull(passphrase) { "A null passphrase is not allowed." } + val pass = words.joinToString(" ") + val salt = "mnemonic$passphrase" + val watch = Stopwatch.createStarted() + val seed = PBKDF2SHA512.derive(pass, salt, PBKDF2_ROUNDS, 64) + watch.stop() + Timber.i("PBKDF2 took {} %s", watch) + return seed + } + + private fun openDefaultWords(resources: Resources?): InputStream? { + resources ?: return null + val stream = resources.openRawResource(R.raw.english) + ?: throw FileNotFoundException(BIP39_ENGLISH_RESOURCE_NAME) + return stream + } + + private fun bytesToBits(data: ByteArray): BooleanArray { + val bits = BooleanArray(data.size * 8) + for (i in data.indices) + for (j in 0 until 8) + bits[i * 8 + j] = (data[i].toInt() and (1 shl (7 - j))) != 0 + return bits + } + + private fun ByteArray.toHexString(): 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() + } + } +} diff --git a/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicException.kt b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicException.kt new file mode 100644 index 0000000000..ff3e3ea98b --- /dev/null +++ b/libs/encryption/mnemonic/src/main/kotlin/com/getcode/crypt/MnemonicException.kt @@ -0,0 +1,33 @@ +package com.getcode.crypt + +/* + * Copyright 2013 Ken Sedgwick + * + * 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. + */ + +/** Exceptions thrown by the [MnemonicCode] module. */ +sealed class MnemonicException(message: String? = null) : Exception(message) { + + /** Thrown when an argument to [MnemonicCode] is the wrong length. */ + class MnemonicLengthException(message: String) : MnemonicException(message) + + /** Thrown when a list of [MnemonicCode] words fails the checksum check. */ + class MnemonicChecksumException : MnemonicException() + + /** Thrown when a word is encountered which is not in the [MnemonicCode]'s word list. */ + class MnemonicWordException( + /** The word that was not found in the word list. */ + val badWord: String, + ) : MnemonicException() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b2c186780f..8449301bf8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -140,6 +140,7 @@ include( ":libs:emojis", ":libs:encryption:base58", ":libs:encryption:ed25519", + ":libs:encryption:ed25519-native", ":libs:encryption:hmac", ":libs:encryption:keys", ":libs:encryption:mnemonic", @@ -252,9 +253,13 @@ val kmpUnitTestModules = setOf( ":libs:encryption:sha256", ":libs:encryption:sha512", ":libs:encryption:hmac", + // ed25519's androidHostTest is a JNI vector test that needs a host-native lib + // (macOS .dylib / Linux .so); it can't load on the Linux CI runner. Its ed25519.json + // parity is gated via the iOS cinterop path (macOS) instead — so it's excluded here. ":libs:encryption:utils", ) -val noUnitTestModules = setOf(":apps:flipcash:benchmark", ":kmp:shared-core") +// ed25519 excluded: its only test is a JNI host vector test that can't run on Linux CI (see kmpUnitTestModules). +val noUnitTestModules = setOf(":apps:flipcash:benchmark", ":kmp:shared-core", ":libs:encryption:ed25519") val unitTestCandidates = includedProjectPaths.filter { path -> unitTestPaths.any { path == it || path.startsWith("$it:") } && path !in noUnitTestModules }