diff --git a/lib/src/commonMain/kotlin/org/komputing/kbech32/AddressFormatException.kt b/lib/src/commonMain/kotlin/org/komputing/kbech32/AddressFormatException.kt new file mode 100644 index 00000000..780caa38 --- /dev/null +++ b/lib/src/commonMain/kotlin/org/komputing/kbech32/AddressFormatException.kt @@ -0,0 +1,30 @@ +package org.komputing.kbech32 + +sealed class AddressFormatException(message: String) : IllegalArgumentException(message) { + + /** + * This exception is thrown by [Bech32] when you try to decode data and a character isn't valid. + * You shouldn't allow the user to proceed in this case. + */ + class InvalidCharacter(character: Char, position: Int) : + AddressFormatException("Invalid character '$character' at position $position") + + /** + * This exception is thrown by [Bech32] when you try to decode data and the data isn't of the + * right size. You shouldn't allow the user to proceed in this case. + */ + class InvalidDataLength(message: String) : AddressFormatException(message) + + /** + * This exception is thrown by [Bech32] when you try to decode data and the checksum isn't valid. + * You shouldn't allow the user to proceed in this case. + */ + class InvalidChecksum : AddressFormatException("Checksum does not validate") + + /** + * This exception is thrown by [Bech32] when you try and decode an + * address or private key with an invalid prefix (version header or human-readable part). You shouldn't allow the + * user to proceed in this case. + */ + class InvalidPrefix(message: String) : AddressFormatException(message) +} diff --git a/lib/src/commonMain/kotlin/org/komputing/kbech32/Bech32.kt b/lib/src/commonMain/kotlin/org/komputing/kbech32/Bech32.kt new file mode 100644 index 00000000..75bce75e --- /dev/null +++ b/lib/src/commonMain/kotlin/org/komputing/kbech32/Bech32.kt @@ -0,0 +1,153 @@ +package org.komputing.kbech32 + +/** + * Bech32 Kotlin implementation. + * + * Taken from [Bitcoinj Bech32 Java implementation](https://github.com/bitcoinj/bitcoinj/blob/master/core/src/main/java/org/bitcoinj/core/Bech32.java) + */ +object Bech32 { + /** The Bech32 character set for encoding. */ + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + /** The Bech32 character set for decoding. */ + private val CHARSET_REV = byteArrayOf( + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, + -1, -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, 1, 0, 3, 16, 11, + 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, + 22, 31, 27, 19, -1, 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 + ) + + /** Find the polynomial with value coefficients mod the generator as 30-bit. */ + private fun polymod(values: ByteArray): Int { + var c = 1 + for (v_i in values) { + val c0 = c.ushr(25) and 0xff + c = c and 0x1ffffff shl 5 xor (v_i.toInt() and 0xff) + if (c0 and 1 != 0) c = c xor 0x3b6a57b2 + if (c0 and 2 != 0) c = c xor 0x26508e6d + if (c0 and 4 != 0) c = c xor 0x1ea119fa + if (c0 and 8 != 0) c = c xor 0x3d4233dd + if (c0 and 16 != 0) c = c xor 0x2a1462b3 + } + return c + } + + /** Expand a HRP for use in checksum computation. */ + private fun expandHrp(hrp: String): ByteArray { + val hrpLength = hrp.length + val ret = ByteArray(hrpLength * 2 + 1) + for (i in 0 until hrpLength) { + val c = hrp[i].toInt() and 0x7f // Limit to standard 7-bit ASCII + ret[i] = (c.ushr(5) and 0x07).toByte() + ret[i + hrpLength + 1] = (c and 0x1f).toByte() + } + ret[hrpLength] = 0 + return ret + } + + /** Verify a checksum. */ + private fun verifyChecksum(hrp: String, values: ByteArray): Boolean { + val hrpExpanded = expandHrp(hrp) + val combined = ByteArray(hrpExpanded.size + values.size) + hrpExpanded.copyInto(combined) + values.copyInto(combined, destinationOffset = hrpExpanded.size) + return polymod(combined) == 1 + } + + /** Create a checksum. */ + private fun createChecksum(hrp: String, values: ByteArray): ByteArray { + val hrpExpanded = expandHrp(hrp) + val enc = ByteArray(hrpExpanded.size + values.size + 6) + hrpExpanded.copyInto(enc) + values.copyInto(enc, startIndex = 0, destinationOffset = hrpExpanded.size) + + val mod = polymod(enc) xor 1 + val ret = ByteArray(6) + for (i in 0..5) { + ret[i] = (mod.ushr(5 * (5 - i)) and 31).toByte() + } + return ret + } + + /** + * Encodes a Bech32 string. + */ + fun encode(bech32: Bech32Data): String { + return encode(bech32.humanReadablePart, bech32.data) + } + + /** + * Encodes a Bech32 string. + */ + fun encode(humanReadablePart: String, data: ByteArray): String { + var hrp = humanReadablePart + + check(hrp.isNotEmpty()) { "Human-readable part is too short" } + check(hrp.length <= 83) { "Human-readable part is too long" } + + hrp = hrp.toLowerCase() + val checksum = createChecksum(hrp, data) + val combined = ByteArray(data.size + checksum.size) + data.copyInto(combined) + checksum.copyInto(combined, startIndex = 0, destinationOffset = data.size) + + val sb = StringBuilder(hrp.length + 1 + combined.size) + sb.append(hrp) + sb.append('1') + for (b in combined) { + sb.append(CHARSET.get(b.toInt())) + } + return sb.toString() + } + + /** + * Decodes a Bech32 string. + */ + fun decode(str: String): Bech32Data { + var lower = false + var upper = false + if (str.length < 8) + throw AddressFormatException.InvalidDataLength("Input too short: " + str.length) + if (str.length > 90) + throw AddressFormatException.InvalidDataLength("Input too long: " + str.length) + for (i in 0 until str.length) { + val c = str[i] + if (c.toInt() < 33 || c.toInt() > 126) throw AddressFormatException.InvalidCharacter( + c, + i + ) + if (c in 'a'..'z') { + if (upper) + throw AddressFormatException.InvalidCharacter(c, i) + lower = true + } + if (c in 'A'..'Z') { + if (lower) + throw AddressFormatException.InvalidCharacter(c, i) + upper = true + } + } + val pos = str.lastIndexOf('1') + if (pos < 1) throw AddressFormatException.InvalidPrefix("Missing human-readable part") + val dataPartLength = str.length - 1 - pos + if (dataPartLength < 6) throw AddressFormatException.InvalidDataLength("Data part too short: $dataPartLength") + val values = ByteArray(dataPartLength) + for (i in 0 until dataPartLength) { + val c = str[i + pos + 1] + if (CHARSET_REV[c.toInt()].toInt() == -1) throw AddressFormatException.InvalidCharacter( + c, + i + pos + 1 + ) + values[i] = CHARSET_REV[c.toInt()] + } + val hrp = str.substring(0, pos).lowercase() + if (!verifyChecksum( + hrp, + values + ) + ) throw AddressFormatException.InvalidChecksum() + return Bech32Data(hrp, values.copyOfRange(0, values.size - 6)) + } +} diff --git a/lib/src/commonMain/kotlin/org/komputing/kbech32/Bech32Data.kt b/lib/src/commonMain/kotlin/org/komputing/kbech32/Bech32Data.kt new file mode 100644 index 00000000..8b22e51c --- /dev/null +++ b/lib/src/commonMain/kotlin/org/komputing/kbech32/Bech32Data.kt @@ -0,0 +1,21 @@ +package org.komputing.kbech32 + +data class Bech32Data(val humanReadablePart: String, val data: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as Bech32Data + + if (humanReadablePart != other.humanReadablePart) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = humanReadablePart.hashCode() + result = 31 * result + data.contentHashCode() + return result + } +} diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/account/Account.kt b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/account/Account.kt index 5835d267..b7c3398b 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/account/Account.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/account/Account.kt @@ -16,6 +16,7 @@ package xyz.mcxross.ksui.account import xyz.mcxross.ksui.core.crypto.Ed25519PrivateKey +import xyz.mcxross.ksui.core.crypto.PrivateKey import xyz.mcxross.ksui.core.crypto.PublicKey import xyz.mcxross.ksui.core.crypto.SignatureScheme import xyz.mcxross.ksui.core.crypto.importFromMnemonic @@ -88,20 +89,25 @@ abstract class Account { /** * Imports an account using the provided mnemonic phrase and signature scheme. * - * @param phrase The mnemonic phrase of the account. + * @param str The mnemonic phrase of the account or the private key. And in which case, the + * private key is expected to be in the format of a Bech32 encoded string. * @param scheme The signature scheme to use. Defaults to ED25519. * @return The imported account. * @throws SignatureSchemeNotSupportedException If the specified signature scheme is not * supported. */ - fun import(phrase: String, scheme: SignatureScheme = SignatureScheme.ED25519): Account { - return when (scheme) { - SignatureScheme.ED25519 -> { - val keyPair = importFromMnemonic(phrase) - Ed25519Account(Ed25519PrivateKey(keyPair.privateKey), phrase) + fun import(str: String, scheme: SignatureScheme = SignatureScheme.ED25519): Account { + if (str.contains(" ")) { + return when (scheme) { + SignatureScheme.ED25519 -> { + val keyPair = importFromMnemonic(str) + Ed25519Account(Ed25519PrivateKey(keyPair.privateKey), str) + } + else -> throw SignatureSchemeNotSupportedException() } - else -> throw SignatureSchemeNotSupportedException() } + + return import(PrivateKey.fromEncoded(str)) } /** @@ -121,5 +127,20 @@ abstract class Account { else -> throw SignatureSchemeNotSupportedException() } } + + /** + * Imports an account using the provided private key. + * + * @param privateKey The private key of the account. + * @return The imported account. + * @throws SignatureSchemeNotSupportedException If the specified signature scheme is not + * supported. + */ + fun import(privateKey: PrivateKey): Account { + return when (privateKey) { + is Ed25519PrivateKey -> Ed25519Account(privateKey) + else -> throw SignatureSchemeNotSupportedException() + } + } } } diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Ed25519.kt b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Ed25519.kt index 237e5f35..4b89d0c0 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Ed25519.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Ed25519.kt @@ -15,9 +15,17 @@ */ package xyz.mcxross.ksui.core.crypto +import xyz.mcxross.ksui.account.Account import xyz.mcxross.ksui.core.Hex - +/** + * This class represents an Ed25519 private key. + * + * Creating a private key instance by either generating or _importing_ will not generate a + * passphrase. If you want to generate a passphrase, you can use the [Account.create] method. + * + * @property privateKey The private key data. + */ class Ed25519PrivateKey(private val privateKey: ByteArray) : PrivateKey { override val data: ByteArray @@ -25,6 +33,26 @@ class Ed25519PrivateKey(private val privateKey: ByteArray) : PrivateKey { override val publicKey: Ed25519PublicKey get() = derivePublicKey(this, SignatureScheme.ED25519) as Ed25519PublicKey + + /** + * Creates a new [Ed25519PrivateKey] with a randomly generated private key. + * + * Default signature scheme is [SignatureScheme.ED25519]. + * + * @param scheme The signature scheme to use. + * @throws IllegalArgumentException If the signature scheme is not supported. + */ + constructor(scheme: SignatureScheme = SignatureScheme.ED25519) : this(generatePrivateKey(scheme)) + + /** + * Creates a new [Ed25519PrivateKey] from an encoded private key. + * + * The expected format is a Bech32 encoded private key. + * + * @param privateKey The encoded private key. + * @throws IllegalArgumentException If the private key is invalid. + */ + constructor(privateKey: String) : this(PrivateKey.fromEncoded(privateKey).data) } class Ed25519PublicKey(val data: ByteArray) : PublicKey { diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Platform.kt b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Platform.kt index 49c24da9..03c445b3 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Platform.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/Platform.kt @@ -15,6 +15,8 @@ */ package xyz.mcxross.ksui.core.crypto +import xyz.mcxross.ksui.exception.SignatureSchemeNotSupportedException + expect fun hash(hash: Hash, data: ByteArray): ByteArray expect fun generateMnemonic(): String @@ -28,3 +30,16 @@ expect fun derivePublicKey(privateKey: PrivateKey, schema: SignatureScheme): Pub expect fun importFromMnemonic(mnemonic: String): KeyPair expect fun importFromMnemonic(mnemonic: List): KeyPair + +fun generatePrivateKey(scheme: SignatureScheme): ByteArray { + return when (scheme) { + SignatureScheme.ED25519 -> { + val seedPhrase = generateMnemonic().split(" ") + val seed = generateSeed(seedPhrase) + generateKeyPair(seed, SignatureScheme.ED25519).privateKey + } + else -> { + throw SignatureSchemeNotSupportedException() + } + } +} diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/PrivateKey.kt b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/PrivateKey.kt index 4eafa7a4..d81546d7 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/PrivateKey.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/core/crypto/PrivateKey.kt @@ -15,7 +15,65 @@ */ package xyz.mcxross.ksui.core.crypto +import org.komputing.kbech32.Bech32 +import xyz.mcxross.ksui.exception.SignatureSchemeNotSupportedException +import xyz.mcxross.ksui.util.SUI_PRIVATE_KEY_PREFIX +import xyz.mcxross.ksui.util.convertBits + +/** + * This interface defines the `PrivateKey` interface, which represents a private key in the SUI + * blockchain. The private key is used to sign transactions and messages. + * + * The `[PrivateKey]` interface also implements SIP-15 for its private key import and export + * methods. This is to visually distinguish a 32-byte private key representation from a 32-bytes Sui + * address that is currently also Hex encoded + */ interface PrivateKey { val data: ByteArray val publicKey: PublicKey + + /** + * Encodes the private key using Bech32 with the specified human-readable part. + * + * The default human-readable part is [SUI_PRIVATE_KEY_PREFIX]. + * + * @param humanReadablePart The human-readable part to use in the Bech32 encoding. Defaults to + * [SUI_PRIVATE_KEY_PREFIX]. + * @return The Bech32 encoded private key. + */ + fun export(humanReadablePart: String = SUI_PRIVATE_KEY_PREFIX): String { + require(humanReadablePart.length in 1..83) { + "Human readable part must be between 1 and 83 characters" + } + + val flag = + when (this) { + is Ed25519PrivateKey -> SignatureScheme.ED25519.scheme + else -> throw SignatureSchemeNotSupportedException() + } + + return Bech32.encode( + humanReadablePart = humanReadablePart, + data = convertBits(byteArrayOf(flag) + data, 8, 5, true), + ) + } + + companion object { + + /** + * Creates a PrivateKey instance from a Bech32 encoded string. + * + * @param encoded The Bech32 encoded private key. + * @return The PrivateKey instance. + * @throws SignatureSchemeNotSupportedException If the signature scheme is not supported. + */ + fun fromEncoded(encoded: String): PrivateKey { + val convertedBit = convertBits(Bech32.decode(encoded).data, 5, 8, false) + return when (convertedBit[0]) { + SignatureScheme.ED25519.scheme -> + Ed25519PrivateKey(convertedBit.sliceArray(1 until convertedBit.size)) + else -> throw SignatureSchemeNotSupportedException() + } + } + } } diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Const.kt b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Const.kt index a47925b4..0e70cddd 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Const.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Const.kt @@ -20,3 +20,5 @@ const val LENGTH: Int = 32 const val PACKAGE_ID = "packageId" const val MODULE = "module" const val FUNCTION = "function" + +const val SUI_PRIVATE_KEY_PREFIX = "suiprivkey" diff --git a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Helper.kt b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Helper.kt index 80671252..34e63fce 100644 --- a/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Helper.kt +++ b/lib/src/commonMain/kotlin/xyz/mcxross/ksui/util/Helper.kt @@ -44,3 +44,27 @@ fun idToParts(id: String): Triple { } return Triple(parts[0], parts[1], parts[2]) } + +fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): ByteArray { + var acc = 0 + var bits = 0 + val maxv = (1 shl toBits) - 1 + val result = mutableListOf() + + for (value in data) { + acc = (acc shl fromBits) or (value.toInt() and 0xff) + bits += fromBits + while (bits >= toBits) { + bits -= toBits + result.add(((acc shr bits) and maxv).toByte()) + } + } + + if (pad && bits > 0) { + result.add(((acc shl (toBits - bits)) and maxv).toByte()) + } else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxv) != 0) { + throw IllegalArgumentException("Invalid bits!") + } + + return result.toByteArray() +} diff --git a/lib/src/commonTest/kotlin/xyz.mcxross.ksui/Const.kt b/lib/src/commonTest/kotlin/xyz.mcxross.ksui/Const.kt new file mode 100644 index 00000000..aa64f7fe --- /dev/null +++ b/lib/src/commonTest/kotlin/xyz.mcxross.ksui/Const.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 McXross + * + * 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. + */ +package xyz.mcxross.ksui + + +const val PRIVATE_KEY_DATA = "suiprivkey1qqtp4ugtv40c6tj4a7r4vd8ft4nykpxsrh07yqssklraxy243us5qyczx9z" diff --git a/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/AccountTest.kt b/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/AccountTest.kt index c7ec8b13..de80b1d8 100644 --- a/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/AccountTest.kt +++ b/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/AccountTest.kt @@ -17,9 +17,11 @@ package xyz.mcxross.ksui.unit import kotlin.test.Test import kotlin.test.assertTrue +import xyz.mcxross.ksui.PRIVATE_KEY_DATA import xyz.mcxross.ksui.account.Account import xyz.mcxross.ksui.account.Ed25519Account import xyz.mcxross.ksui.core.crypto.Ed25519PublicKey +import xyz.mcxross.ksui.core.crypto.PrivateKey import xyz.mcxross.ksui.core.crypto.SignatureScheme class AccountTest { @@ -51,7 +53,7 @@ class AccountTest { } @Test - fun testAccountImport() { + fun testAccountImportPhrase() { val account = Account.import("dry clock defense build educate lonely cycle hand phrase kitchen enemy seed") assertTrue( @@ -75,4 +77,22 @@ class AccountTest { account.address.toString().length == 66 } } + + @Test + fun testAccountImportPrivateKeyString() { + val account = Account.import(PRIVATE_KEY_DATA) + assertTrue { + account.address.toString() == + "0x7aaec1a24ced4f34d49c27f00b21f5e3c7a9b20f25e57a1fd2863b15abe3a904" + } + } + + @Test + fun testAccountImportPrivateKeyInstance() { + val account = Account.import(PrivateKey.fromEncoded(PRIVATE_KEY_DATA)) + assertTrue { + account.address.toString() == + "0x7aaec1a24ced4f34d49c27f00b21f5e3c7a9b20f25e57a1fd2863b15abe3a904" + } + } } diff --git a/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/PrivateKeyTest.kt b/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/PrivateKeyTest.kt new file mode 100644 index 00000000..3c2b13f0 --- /dev/null +++ b/lib/src/commonTest/kotlin/xyz.mcxross.ksui/unit/PrivateKeyTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2024 McXross + * + * 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. + */ +package xyz.mcxross.ksui.unit + +import kotlin.test.Test +import kotlin.test.assertTrue +import xyz.mcxross.ksui.PRIVATE_KEY_DATA +import xyz.mcxross.ksui.core.crypto.Ed25519PrivateKey +import xyz.mcxross.ksui.core.crypto.PrivateKey + +class PrivateKeyTest { + + @Test + fun testPrivateKeyGeneration() { + val privateKey = Ed25519PrivateKey() + assertTrue { privateKey.data.size == 32 } + } + + @Test + fun testPrivateKeyImportConstructor() { + val privateKey = Ed25519PrivateKey(PRIVATE_KEY_DATA) + assertTrue { privateKey.data.size == 32 } + assertTrue { privateKey.export() == PRIVATE_KEY_DATA } + } + + @Test + fun testPrivateKeyImportFromEncoded() { + val privateKey = PrivateKey.fromEncoded(PRIVATE_KEY_DATA) + assertTrue { privateKey.data.size == 32 } + assertTrue { privateKey.export() == PRIVATE_KEY_DATA } + } +}