Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiplatform #14

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ build/
local.properties

.DS_Store

40 changes: 40 additions & 0 deletions bip39-lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,29 @@ plugins {
id("signing")
}

val enableNative = project.property("NATIVE_TARGETS_ENABLED").toString().toBoolean()
val nativeTargets = if (enableNative) arrayOf(
"linuxX64",
"macosX64", "macosArm64",
"iosArm64", "iosX64", "iosSimulatorArm64",
"tvosArm64", "tvosX64", "tvosSimulatorArm64",
"watchosArm32", "watchosArm64", "watchosX86", "watchosX64", "watchosSimulatorArm64",
"mingwX64"
) else arrayOf()

kotlin {
jvm {
testRuns["test"].executionTask.configure {
useJUnitPlatform()
}
}
js(IR) {
browser() // to compile for the web
nodejs() // to compile against node
}
for (target in nativeTargets) {
targets.add(presets.getByName(target).createTarget(target))
}

sourceSets {
val commonMain by getting {
Expand All @@ -39,6 +56,29 @@ kotlin {
implementation(libs.kotest.runner.junit5)
}
}
val nonJvmMain by creating {
dependsOn(commonMain)
dependencies {
implementation(libs.com.squareup.okio)
}
}
val jsMain by getting {
dependsOn(nonJvmMain)
}
val nativeMain by creating {
dependsOn(nonJvmMain)
}
val unixMain by creating {
dependsOn(nonJvmMain)
}
for (target in nativeTargets) {
when (target) {
"mingwX64" ->
getByName("${target}Main").dependsOn(nativeMain)
else ->
getByName("${target}Main").dependsOn(unixMain)
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ import cash.z.ecc.android.bip39.Mnemonics.KEY_SIZE
import cash.z.ecc.android.bip39.Mnemonics.MnemonicCode
import cash.z.ecc.android.bip39.Mnemonics.PBE_ALGORITHM
import cash.z.ecc.android.bip39.Mnemonics.WordCount
import cash.z.ecc.android.common.Closeable
import cash.z.ecc.android.crypto.FallbackProvider
import java.io.Closeable
import java.nio.CharBuffer
import java.nio.charset.Charset
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import cash.z.ecc.android.crypto.PBEKeySpecCommon
import cash.z.ecc.android.crypto.SecretKeyFactoryCommon
import cash.z.ecc.android.random.SecureRandom
import kotlin.experimental.or

/**
Expand All @@ -25,8 +21,10 @@ object Mnemonics {
const val DEFAULT_PASSPHRASE = "mnemonic"
const val INTERATION_COUNT = 2048
const val KEY_SIZE = 512
const val DEFAULT_LANGUAGE_CODE = "en"

internal val secureRandom = SecureRandom()
@Suppress("VARIABLE_IN_SINGLETON_WITHOUT_THREAD_LOCAL")
internal var cachedList = WordList()

fun getCachedWords(languageCode: String): List<String> {
Expand All @@ -36,26 +34,27 @@ object Mnemonics {
return cachedList.words
}


//
// Inner Classes
//

class MnemonicCode(val chars: CharArray, val languageCode: String = Locale.ENGLISH.language) :
class MnemonicCode(val chars: CharArray, val languageCode: String = DEFAULT_LANGUAGE_CODE) :
Closeable, Iterable<String> {

constructor(
phrase: String,
languageCode: String = Locale.ENGLISH.language
languageCode: String = DEFAULT_LANGUAGE_CODE
) : this(phrase.toCharArray(), languageCode)

constructor(
entropy: ByteArray,
languageCode: String = Locale.ENGLISH.language
languageCode: String = DEFAULT_LANGUAGE_CODE
) : this(computeSentence(entropy), languageCode)

constructor(
wordCount: WordCount,
languageCode: String = Locale.ENGLISH.language
languageCode: String = DEFAULT_LANGUAGE_CODE
) : this(computeSentence(wordCount.toEntropy()), languageCode)

override fun close() = clear()
Expand Down Expand Up @@ -88,7 +87,7 @@ object Mnemonics {

override fun next(): String {
val nextSpaceIndex = nextSpaceIndex()
val word = String(chars, cursor, nextSpaceIndex - cursor)
val word = chars.concatToString(cursor, cursor + (nextSpaceIndex - cursor))
cursor = nextSpaceIndex + 1
return word
}
Expand Down Expand Up @@ -141,11 +140,9 @@ object Mnemonics {
* Get the original entropy that was used to create this MnemonicCode. This call will fail
* if the words have an invalid length or checksum.
*
* @InvalidWordException If any word isn't in the word list
* @throws WordCountException when the word count is zero or not a multiple of 3.
* @throws ChecksumException if the checksum does not match the expected value.
*/
@Suppress("ThrowsCount", "NestedBlockDepth")
fun toEntropy(): ByteArray {
wordCount.let { if (it <= 0 || it % 3 > 0) throw WordCountException(wordCount) }

Expand Down Expand Up @@ -215,7 +212,7 @@ object Mnemonics {
*/
private fun computeSentence(
entropy: ByteArray,
languageCode: String = Locale.ENGLISH.language
languageCode: String = DEFAULT_LANGUAGE_CODE
): CharArray {
// initialize state
var index = 0
Expand Down Expand Up @@ -286,6 +283,7 @@ object Mnemonics {
}
}


//
// Typed Exceptions
//
Expand Down Expand Up @@ -335,19 +333,21 @@ fun MnemonicCode.toSeed(
// we can skip validation when we know for sure that the code is valid
// such as when it was just generated from new/correct entropy (common case for new seeds)
if (validate) validate()
return (DEFAULT_PASSPHRASE.toCharArray() + passphrase).toBytes().let { salt ->
PBEKeySpec(chars, salt, INTERATION_COUNT, KEY_SIZE).let { pbeKeySpec ->
runCatching {
SecretKeyFactory.getInstance(PBE_ALGORITHM)
}.getOrElse {
SecretKeyFactory.getInstance(PBE_ALGORITHM, FallbackProvider())
}.let { keyFactory ->
keyFactory.generateSecret(pbeKeySpec).encoded.also {
pbeKeySpec.clearPassword()
return (DEFAULT_PASSPHRASE.toCharArray() + passphrase)
.map { it.code.toByte() }.toByteArray()
.let { salt ->
PBEKeySpecCommon(chars, salt, INTERATION_COUNT, KEY_SIZE).let { pbeKeySpec ->
runCatching {
SecretKeyFactoryCommon.getInstance(PBE_ALGORITHM)
}.getOrElse {
SecretKeyFactoryCommon.getInstance(PBE_ALGORITHM, FallbackProvider())
}.let { keyFactory ->
keyFactory.generateSecret(pbeKeySpec).encoded.also {
pbeKeySpec.clearPassword()
}
}
}
}
}
}

fun WordCount.toEntropy(): ByteArray = ByteArray(bitLength / 8).apply {
Expand All @@ -358,13 +358,9 @@ fun WordCount.toEntropy(): ByteArray = ByteArray(bitLength / 8).apply {
// Private Extensions
//

private fun ByteArray?.toSha256() = MessageDigest.getInstance("SHA-256").digest(this)
internal expect fun ByteArray.toSha256() : ByteArray

private fun ByteArray.toBits(): List<Boolean> = flatMap { it.toBits() }

private fun Byte.toBits(): List<Boolean> = (7 downTo 0).map { (toInt() and (1 shl it)) != 0 }

private fun CharArray.toBytes(): ByteArray {
val byteBuffer = CharBuffer.wrap(this).let { Charset.forName("UTF-8").encode(it) }
return byteBuffer.array().copyOfRange(byteBuffer.position(), byteBuffer.limit())
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
package cash.z.ecc.android.bip39

import java.lang.UnsupportedOperationException
import java.util.*
import cash.z.ecc.android.bip39.Mnemonics.DEFAULT_LANGUAGE_CODE

/**
* A Cached list of words. This serves as an abstraction, allowing collaborators to be agnostic
* about the source of words. Right now, words are kept in memory since only english is supported
* but, eventually, they will come from the file system and library users should not have to change
* any code.
*/
class WordList internal constructor(val languageCode: String) {
constructor(locale: Locale = Locale.ENGLISH) : this(locale.language)
class WordList internal constructor(val languageCode: String = DEFAULT_LANGUAGE_CODE) {

init {
validate(languageCode)
Expand All @@ -31,7 +29,7 @@ class WordList internal constructor(val languageCode: String) {
* Returns true when the given language code (like "en") is supported. Currently, only
* English is supported but this will change in future versions.
*/
fun isSupported(languageCode: String): Boolean = languageCode == Locale.ENGLISH.language
fun isSupported(languageCode: String): Boolean = languageCode == DEFAULT_LANGUAGE_CODE

/**
* Throws an error when the given language code is not supported.
Expand All @@ -52,7 +50,6 @@ class WordList internal constructor(val languageCode: String) {
*
* @return a list of words matching the given language code.
*/
@Suppress("LongMethod")
private fun fetchWords(languageCode: String): List<String> {
validate(languageCode)
return """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package cash.z.ecc.android.common

expect interface Closeable {
fun close()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package cash.z.ecc.android.crypto

expect class FallbackProvider()
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cash.z.ecc.android.crypto

expect class PBEKeySpecCommon(password: CharArray?, salt: ByteArray?, iterationCount: Int, keyLength: Int) {

var password: CharArray?
var salt: ByteArray?
var iterationCount: Int
var keyLength: Int

fun clearPassword()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cash.z.ecc.android.crypto

/**
*
* 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/<br></br>
* Modified to use SHA-512 - Ken Sedgwick ken@bonsai.com
* Modified to for Kotlin - Kevin Gorham anothergmale@gmail.com
*/
expect object Pbkdf2Sha512 {

/**
* Generate a derived key from the given parameters.
*
* @param p the password
* @param s the salt
* @param c the iteration count
* @param dkLen the key length in bits
*/
fun derive(p: CharArray, s: ByteArray, c: Int, dkLen: Int): ByteArray

internal fun F(p: ByteArray, s: ByteArray, c: Int, i: Int): ByteArray

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package cash.z.ecc.android.crypto

expect class SecretKeyCommon {
val encoded: ByteArray

}



Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package cash.z.ecc.android.crypto

expect class SecretKeyFactoryCommon {
fun generateSecret(pbeKeySpec: PBEKeySpecCommon): SecretKeyCommon

companion object {
fun getInstance(algorithm: String): SecretKeyFactoryCommon
fun getInstance(algorithm: String, provider: FallbackProvider): SecretKeyFactoryCommon
}
}



Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package cash.z.ecc.android.random

expect class SecureRandom() {
fun nextBytes(bytes: ByteArray)
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ class MnemonicsTest : BehaviorSpec({
Mnemonics.WordCount.values().forEach { wordCount ->
When("a mnemonic phrase is created using the ${wordCount.name} enum value") {
MnemonicCode(wordCount).let { phrase ->
String(phrase.chars).asClue { phraseString ->
phrase.chars.concatToString().asClue { phraseString ->
Then("it has ${wordCount.count - 1} spaces") {
phrase.chars.count { it == ' ' } shouldBe wordCount.count - 1
}
And("when that is converted to a list of CharArrays") {
phrase.words.map { String(it) }.asClue { words ->
phrase.words.map { it.concatToString() }.asClue { words ->
Then("It has ${wordCount.count} elements") {
words.size shouldBe wordCount.count
}
Expand Down Expand Up @@ -118,7 +118,7 @@ class MnemonicsTest : BehaviorSpec({
)
) { _, entropy, mnemonic ->
val code = MnemonicCode(entropy.fromHex())
String(code.chars) shouldBe mnemonic
code.chars.concatToString() shouldBe mnemonic
}
}
}
Expand All @@ -131,7 +131,7 @@ class MnemonicsTest : BehaviorSpec({
englishTestData.forEach {
val entropy = it[0].fromHex()
val mnemonic = it[1]
String(MnemonicCode(entropy).chars) shouldBe mnemonic
MnemonicCode(entropy).chars.concatToString() shouldBe mnemonic
}
}
}
Expand Down
Loading