Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010 #71

Merged
paulhitt merged 3 commits from feat/wallet-engine-primitives into main 2026-09-04 20:49:33 +00:00
6 changed files with 2845 additions and 4 deletions
Showing only changes of commit 4ee0170dcc - Show all commits
+28 -4
View File
@@ -339,14 +339,38 @@ the messenger rather than staying separate apps.
| Local vault: add, organise, delete | ⚪ |
| Bell–LaPadula labels and clearance | ⚪ |
| Share a file into a chat or group | ⚪ |
| Wallet engine: BIP-39, Base58, SLIP-0010 | 🟡 primitives built and pinned to the published vectors; no screen yet |
| Wallet: BIP-39 seed, create and restore | ⚪ |
| Wallet: fresh receive address per receive | ⚪ |
| Wallet: balance and transactions from a node | ⚪ |
⚠️ Nothing here exists on Android yet. The iOS side is built and tested; the
engine work it rests on (BIP-39, SLIP-0010, Base58, the label encoding) is
Swift-only and has no Kotlin counterpart, so this is the largest remaining gap
and it is a build, not a port of a screen.
⚠️ Nothing above the engine exists on Android yet. This is the largest
remaining gap and it is a build, not a port of a screen.
🔴 **The wallet primitives are now in Kotlin and pinned to the STANDARDS, not to
the Swift engine.** `Bip39`, `Base58` and `HdKey` are held to BIP-39's own
vectors, SLIP-0010's own ed25519 vectors and the published word-list SHA-256 —
the same things the Swift side is held to. Checking one implementation against
the other would only prove they agree, and two implementations agreeing on the
wrong answer is exactly how a wallet ends up empty: a phrase written down on an
iPhone has to restore on an Android handset, and on somebody else's software
years from now.
Every failure in that file is silent in production. A wrong checksum bit, a lost
leading zero, a missed NFKD — none of them error. They derive a different,
perfectly valid, empty wallet.
Two Kotlin-specific decisions, both money-relevant:
- **PBKDF2 is hand-written over `Mac`**, not `SecretKeyFactory`. The JCA path
takes a `CharArray` and the provider decides the encoding — `AccountKdf`
already carries that caveat for the account KDF. For a wallet the consequence
is worse than a failed sign-in: a phrase containing anything outside ASCII
would stretch differently under a different provider and derive a different
empty wallet with no error anywhere.
- **Base58 is a digit loop, not `BigInteger`.** The obvious Kotlin version is
`BigInteger(1, bytes).toString(58)` remapped, and it silently drops leading
zero bytes — encoding two different keys to one address.
---
@@ -0,0 +1,82 @@
package io.flamenet.e2e
/**
* Base58, the alphabet every ed25519 chain prints addresses in.
*
* ⚠️ **The leading-zero rule is the whole difficulty.** Base58 is
* base-conversion of one big integer, and an integer has no way to represent a
* leading zero byte — so the standard prepends one `1` per leading zero byte
* separately. Implementations that forget this encode two different keys to the
* same string, and the failure surfaces as money sent to the wrong address.
* Hence the tests, which are mostly about zeros.
*
* 🔴 **Hand-written rather than `java.math.BigInteger`.** The obvious Kotlin
* implementation is `BigInteger(1, bytes).toString(58)` with a remapped
* alphabet, and it is wrong in exactly the way above: `BigInteger` discards
* leading zeros, so a key beginning `0x00` loses a character and encodes to
* another key's address. The digit loop below is the same one the Swift engine
* runs, so the two cannot disagree.
*/
object Base58 {
/**
* 🔴 Not the Bitcoin alphabet's cousins: `0`, `O`, `I` and `l` are absent
* deliberately, so an address read aloud or copied by hand cannot become a
* different valid one.
*/
const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
private val VALUES: Map<Char, Int> =
ALPHABET.withIndex().associate { (index, character) -> character to index }
fun encode(data: ByteArray): String {
if (data.isEmpty()) return ""
// 🔴 Starts EMPTY, not with a zero digit. Seeded with one, an all-zero
// input produces one digit AND one leading '1', so a single 0x00 byte
// encodes as "11" — every address with a leading zero comes out one
// character too long, and is either rejected by the other end or,
// worse, accepted as a different one.
val digits = ArrayList<Int>()
for (byte in data) {
var carry = byte.toInt() and 0xff
for (position in digits.indices) {
carry += digits[position] shl 8
digits[position] = carry % 58
carry /= 58
}
while (carry > 0) {
digits.add(carry % 58)
carry /= 58
}
}
// One '1' per leading zero byte — see the note above.
val leadingZeros = data.takeWhile { it.toInt() == 0 }.count()
return "1".repeat(leadingZeros) +
digits.reversed().map { ALPHABET[it] }.joinToString("")
}
/**
* Null for any character outside the alphabet, rather than a best guess: a
* mistyped address must fail here, not resolve to somebody else's.
*/
fun decode(text: String): ByteArray? {
if (text.isEmpty()) return ByteArray(0)
val bytes = ArrayList<Int>()
for (character in text) {
var carry = VALUES[character] ?: return null
for (position in bytes.indices) {
carry += bytes[position] * 58
bytes[position] = carry and 0xff
carry = carry shr 8
}
while (carry > 0) {
bytes.add(carry and 0xff)
carry = carry shr 8
}
}
val leadingOnes = text.takeWhile { it == '1' }.count()
val out = ByteArray(leadingOnes + bytes.size)
for (i in bytes.indices) out[leadingOnes + i] = bytes[bytes.size - 1 - i].toByte()
return out
}
}
@@ -0,0 +1,216 @@
package io.flamenet.e2e
import java.security.MessageDigest
import java.text.Normalizer
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* BIP-39 mnemonics, for the wallet only.
*
* 🔴 **Not for anything else here.** Files recovers through its own Crockford
* codes, and the reasoning for rejecting a word list there stands: 2,048
* entries that must be byte-exact is a footgun. The wallet accepts that footgun
* for one reason — a phrase has to be understood by software nobody here
* controls — and pays for it with [verify].
*
* A port of `Packages/FlamenetFiles/Sources/FilesEngine/BIP39.swift`, asserted
* against the same published vectors so the two cannot drift into deriving
* different wallets from one phrase. The vendored word list is byte-identical
* to the iOS one and has the same SHA-256.
*/
object Bip39 {
sealed class Failure(message: String) : Exception(message) {
class BadEntropySize(val size: Int) :
Failure("Entropy must be 16, 20, 24, 28 or 32 bytes, not $size.")
class BadPhraseLength(val words: Int) :
Failure("A recovery phrase is 12, 15, 18, 21 or 24 words, not $words.")
class UnknownWord(val word: String) :
Failure("“$word” is not a recovery phrase word.")
object ChecksumFailed :
Failure("That phrase does not check out — look for a mistyped word.")
class WordlistDigestMismatch(val found: String) :
Failure("The word list does not match its published fingerprint.")
class WordlistWrongLength(val count: Int) :
Failure("A word list has exactly 2048 words, not $count.")
}
/**
* The published SHA-256 of the BIP-39 English word list.
*
* ⚠️ Wrong here fails CLOSED — a correct list would be rejected and nothing
* would derive, which is the safe direction to be wrong in.
*/
const val ENGLISH_DIGEST = "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda"
/**
* 🔴 The whole mitigation. A silently corrupted list does not error — it
* produces a DIFFERENT wallet from the same phrase, and the way somebody
* finds out is restoring and seeing an empty account.
*/
fun verify(words: List<String>, digest: String = ENGLISH_DIGEST) {
if (words.size != 2048) throw Failure.WordlistWrongLength(words.size)
val canonical = words.joinToString("\n") + "\n"
val hex = MessageDigest.getInstance("SHA-256")
.digest(canonical.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
if (hex != digest) throw Failure.WordlistDigestMismatch(hex)
}
private val PERMITTED = mapOf(16 to 12, 20 to 15, 24 to 18, 28 to 21, 32 to 24)
/**
* Entropy to phrase. The checksum is the first `entropy.size / 4` bits of
* SHA-256(entropy), which is what makes a mistyped word fail rather than
* silently name another wallet.
*/
fun phrase(entropy: ByteArray, words: List<String>): String {
if (!PERMITTED.containsKey(entropy.size)) throw Failure.BadEntropySize(entropy.size)
val checksum = MessageDigest.getInstance("SHA-256").digest(entropy)
val bits = StringBuilder()
for (b in entropy) bits.append(bitsOf(b))
for (b in checksum) bits.append(bitsOf(b))
val used = bits.substring(0, entropy.size * 8 + entropy.size / 4)
val out = ArrayList<String>()
var index = 0
while (index < used.length) {
out.add(words[used.substring(index, index + 11).toInt(2)])
index += 11
}
return out.joinToString(" ")
}
/**
* Phrase back to entropy, refusing anything that does not check out.
*
* ⚠️ Every failure is loud. A phrase of the wrong length, containing a word
* not on the list, or failing its checksum is rejected — never coerced into
* the nearest valid thing, which would hand somebody a wallet not theirs.
*/
fun entropy(phrase: String, words: List<String>): ByteArray {
val given = normalise(phrase).trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (given.size !in listOf(12, 15, 18, 21, 24)) throw Failure.BadPhraseLength(given.size)
val lookup = HashMap<String, Int>(words.size * 2)
words.forEachIndexed { at, word -> lookup[word] = at }
val bits = StringBuilder()
for (word in given) {
val at = lookup[word] ?: throw Failure.UnknownWord(word)
bits.append(at.toString(2).padStart(11, '0'))
}
val entropyBits = given.size * 11 * 32 / 33
val entropy = ByteArray(entropyBits / 8)
for (i in entropy.indices) {
entropy[i] = bits.substring(i * 8, i * 8 + 8).toInt(2).toByte()
}
val expected = MessageDigest.getInstance("SHA-256").digest(entropy)
.joinToString("") { bitsOf(it) }
val carried = bits.substring(entropyBits)
if (carried != expected.substring(0, entropyBits / 32)) throw Failure.ChecksumFailed
return entropy
}
/**
* Phrase to the 64-byte seed everything derives from.
*
* ⚠️ The optional passphrase is BIP-39's, not the account's. A different one
* silently yields a DIFFERENT valid wallet — the standard's design, and a
* sharp edge worth naming wherever it is offered to somebody.
*/
fun seed(phrase: String, passphrase: String = ""): ByteArray =
pbkdf2HmacSha512(
password = normalise(phrase).toByteArray(Charsets.UTF_8),
salt = normalise("mnemonic$passphrase").toByteArray(Charsets.UTF_8),
iterations = 2048,
length = 64,
)
/**
* The vendored list, read from the module's resources.
*
* 🔴 Verified on every load rather than once at build. The file ships inside
* the app, and a check that only ran in CI would not notice one that
* arrived corrupted.
*/
fun englishWordlist(): List<String> {
val stream = Bip39::class.java.getResourceAsStream("english.txt")
?: throw Failure.WordlistWrongLength(0)
val words = stream.bufferedReader(Charsets.UTF_8).use { it.readText() }
.trim().split("\n").map { it.trim() }.filter { it.isNotEmpty() }
verify(words)
return words
}
// ---- Internals -------------------------------------------------------
private fun bitsOf(b: Byte): String =
(b.toInt() and 0xff).toString(2).padStart(8, '0')
/**
* 🔴 **NFKD, and it decides which wallet you get.** BIP-39 normalises the
* phrase and the salt before stretching. A phrase typed with composed
* accents and one typed with combining marks are the same words to a
* reader and different bytes to PBKDF2 — so skipping this derives a
* different, perfectly valid, empty wallet.
*/
private fun normalise(text: String): String =
Normalizer.normalize(text, Normalizer.Form.NFKD)
/**
* PBKDF2-HMAC-SHA512.
*
* 🔴 **Hand-written on top of [Mac] rather than through
* `SecretKeyFactory("PBKDF2WithHmacSHA512")`, and the reason is money.**
* The JCA path takes a `CharArray` and the provider decides how to encode
* it — `AccountKdf.master` already carries a note about that for the
* account KDF, where the same ambiguity exists. For a wallet the
* consequence is worse than a failed sign-in: a phrase containing anything
* outside ASCII would stretch to different bytes under a different
* provider, and derive a different wallet that is empty and perfectly
* valid, with no error anywhere. Taking the bytes ourselves removes the
* provider from the decision entirely.
*
* ⚠️ Also: `PBKDF2WithHmacSHA512` is absent on Android below API 26, and
* this engine's floor is lower than the rest of the app's for a reason —
* the JVM tests run it with no emulator.
*/
internal fun pbkdf2HmacSha512(
password: ByteArray,
salt: ByteArray,
iterations: Int,
length: Int,
): ByteArray {
val mac = Mac.getInstance("HmacSHA512")
mac.init(SecretKeySpec(password, "HmacSHA512"))
val blockSize = mac.macLength
val out = ByteArray(length)
var written = 0
var block = 1
while (written < length) {
mac.reset()
mac.update(salt)
mac.update(byteArrayOf(
(block ushr 24).toByte(), (block ushr 16).toByte(),
(block ushr 8).toByte(), block.toByte(),
))
var u = mac.doFinal()
val accumulated = u.copyOf()
for (round in 2..iterations) {
mac.reset()
u = mac.doFinal(u)
for (i in accumulated.indices) {
accumulated[i] = (accumulated[i].toInt() xor u[i].toInt()).toByte()
}
}
val take = minOf(blockSize, length - written)
System.arraycopy(accumulated, 0, out, written, take)
written += take
block++
}
return out
}
}
@@ -0,0 +1,124 @@
package io.flamenet.e2e
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Hierarchical key derivation from a wallet seed. SLIP-0010, ed25519 curve.
*
* 🔴 **Addresses are independent of who you are, by construction.** The seed is
* fresh entropy, never derived from the passphrase, the account name, or the
* Flamenet identity — so no address can be computed from a Flamenet identity
* and no identity from an address. This type only walks a tree downward from
* that seed; nothing identifying is ever an input, and nothing here should ever
* be given one.
*
* ⚠️ **Fresh key per use, not one address reused.** The tree exists so an
* ordinary path — `m/44H/501H/accountH/0H` — yields a new keypair per receive
* without new backup material: the phrase still restores all of them. Reusing a
* single address would tie every payment together on a public ledger for
* anybody who cared to look, which is a privacy property the wallet can control
* and the chain cannot.
*
* 🔴 **ed25519 only, and hardened only.** SLIP-0010 defines no non-hardened
* derivation for this curve — the public key is a compressed Edwards point with
* no usable additive homomorphism — so [derive] refuses an unhardened index
* rather than inventing a scheme. secp256k1 chains need curve arithmetic this
* engine does not carry; supporting them is a dependency decision, not
* something to approximate here.
*/
data class HdKey(
/** 32 bytes. The ed25519 scalar seed, not an expanded secret. */
val privateKey: ByteArray,
/** 32 bytes. Feeds the next derivation, never a key on its own. */
val chainCode: ByteArray,
) {
// ⚠️ ByteArray has reference equality, and a data class inherits it. Two
// keys derived from one path down two objects must compare equal or every
// test here asserts nothing.
override fun equals(other: Any?) = other is HdKey &&
privateKey.contentEquals(other.privateKey) && chainCode.contentEquals(other.chainCode)
override fun hashCode() = 31 * privateKey.contentHashCode() + chainCode.contentHashCode()
sealed class Failure(message: String) : Exception(message) {
class SeedOutOfRange(val size: Int) : Failure("That wallet seed is the wrong size.")
class Unhardened(val index: Long) : Failure("This wallet only derives hardened keys.")
class MalformedPath(val path: String) : Failure("$path isn't a derivation path.")
}
/** One hardened step. [index] may be given with or without the flag set. */
fun derive(index: Long): HdKey {
val hardenedIndex = if (index >= HARDENED) index else index or HARDENED
if (hardenedIndex < HARDENED) throw Failure.Unhardened(index)
// 0x00 || private key || index, big-endian. The leading zero is what
// distinguishes this from the secp256k1 layout and is part of the
// standard, not padding.
val data = ByteArray(1 + 32 + 4)
data[0] = 0
System.arraycopy(privateKey, 0, data, 1, 32)
data[33] = (hardenedIndex ushr 24).toByte()
data[34] = (hardenedIndex ushr 16).toByte()
data[35] = (hardenedIndex ushr 8).toByte()
data[36] = hardenedIndex.toByte()
val bytes = hmacSha512(key = chainCode, data = data)
return HdKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 64))
}
/**
* Walk a path such as `m/44H/501H/0H/0H`.
*
* ⚠️ Accepts `H`, `h` and `'` for hardened, because all three are written
* in the wild and a path copied from documentation should not fail on
* notation. An index with none of them is REFUSED rather than silently
* hardened: somebody writing `m/44` means non-hardened, which this curve
* cannot do, and quietly giving them a different key than every other
* wallet would is the worst possible answer.
*/
fun derive(path: String): HdKey {
val components = path.split("/").filter { it.isNotEmpty() }.toMutableList()
if (components.firstOrNull()?.lowercase() == "m") components.removeAt(0)
var key = this
for (component in components) {
if (!(component.endsWith("H") || component.endsWith("h") || component.endsWith("'"))) {
throw Failure.Unhardened(0)
}
val index = component.dropLast(1).toLongOrNull()
?: throw Failure.MalformedPath(path)
if (index < 0 || index >= HARDENED) throw Failure.MalformedPath(path)
key = key.derive(index)
}
return key
}
companion object {
/** SLIP-0010 fixes this string; changing it changes every key. */
private val CURVE = "ed25519 seed".toByteArray(Charsets.UTF_8)
/** Anything at or above this is hardened. */
const val HARDENED: Long = 0x8000_0000L
/** The master key. SLIP-0010 §Master key generation. */
fun master(seed: ByteArray): HdKey {
// The standard's own bound. BIP-39 gives 64, so this only fires on
// a caller that built a seed some other way.
if (seed.size !in 16..64) throw Failure.SeedOutOfRange(seed.size)
val bytes = hmacSha512(key = CURVE, data = seed)
// ⚠️ ed25519 needs no retry loop. On secp256k1 an IL of zero or ≥ n
// is re-hashed; every 32-byte string is a valid ed25519 scalar seed,
// so there is no such case and adding one would be dead code that
// looks load-bearing.
return HdKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 64))
}
fun master(seed: ByteArray, path: String): HdKey = master(seed).derive(path)
private fun hmacSha512(key: ByteArray, data: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA512")
mac.init(SecretKeySpec(key, "HmacSHA512"))
return mac.doFinal(data)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,347 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* BIP-39, Base58 and SLIP-0010 against the published vectors.
*
* 🔴 **These are pinned to the STANDARDS, not to the Swift engine.** Testing
* one implementation against the other proves only that they agree, and two
* implementations agreeing on the wrong answer is exactly how a wallet ends up
* empty: a phrase written down on an iPhone has to restore on an Android
* handset, and on somebody else's software years from now. So the vectors here
* are BIP-39's own, SLIP-0010's own, and the published word-list digest — which
* the Swift engine is also held to.
*
* ⚠️ **Every failure in this file is silent in production.** A wrong checksum
* bit, a lost leading zero, a missed NFKD: none of them error. They derive a
* different, perfectly valid, empty wallet, and the way somebody finds out is
* restoring their phrase and seeing nothing there.
*/
class WalletPrimitivesTest {
private fun hex(bytes: ByteArray) = bytes.joinToString("") { "%02x".format(it) }
private fun unhex(text: String) = ByteArray(text.length / 2) {
text.substring(it * 2, it * 2 + 2).toInt(16).toByte()
}
private val words by lazy { Bip39.englishWordlist() }
// ---- The word list ---------------------------------------------------
/**
* 🔴 The whole mitigation for vendoring 2,048 strings. A silently corrupted
* list does not error — it produces a DIFFERENT wallet from the same
* phrase.
*/
@Test
fun `the vendored word list matches its published fingerprint`() {
assertEquals(2048, words.size)
Bip39.verify(words)
assertEquals("abandon", words.first())
assertEquals("zoo", words.last())
}
@Test
fun `a corrupted word list is refused`() {
val tampered = words.toMutableList().also { it[7] = "banana" }
assertFailsWith<Bip39.Failure.WordlistDigestMismatch> { Bip39.verify(tampered) }
assertFailsWith<Bip39.Failure.WordlistWrongLength> { Bip39.verify(words.drop(1)) }
}
// ---- BIP-39 vectors --------------------------------------------------
/**
* The canonical first vector from BIP-39's own test file: all-zero entropy.
*/
@Test
fun `all-zero entropy produces the canonical phrase and seed`() {
val entropy = ByteArray(16)
val phrase = Bip39.phrase(entropy, words)
assertEquals(
"abandon abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon about",
phrase,
)
assertContentEquals(entropy, Bip39.entropy(phrase, words))
assertEquals(
"c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e5349553" +
"1f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
hex(Bip39.seed(phrase, passphrase = "TREZOR")),
)
}
/** A 24-word vector, to exercise the longer checksum. */
@Test
fun `the all-ones 32-byte vector round-trips`() {
val entropy = ByteArray(32) { 0xff.toByte() }
val phrase = Bip39.phrase(entropy, words)
assertEquals(24, phrase.split(" ").size)
assertEquals("zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo " +
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote", phrase)
assertContentEquals(entropy, Bip39.entropy(phrase, words))
// ⚠️ This expectation was WRONG in the first version of this file — it
// carried the seed from a different BIP-39 vector, and the Kotlin
// answer below is the correct one. It was resolved by computing the
// value independently (PBKDF2-HMAC-SHA512, NFKD, 2048 rounds) rather
// than by adopting whatever the implementation produced, which is the
// only way a vector test is worth anything: a pinned value taken from
// the code under test pins the bug.
assertEquals(
"dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e16" +
"13912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad",
hex(Bip39.seed(phrase, passphrase = "TREZOR")),
)
}
/** A mid-length vector, so 18 words is not left to inference. */
@Test
fun `an eighteen-word vector round-trips`() {
val entropy = unhex("808080808080808080808080808080808080808080808080")
val phrase = Bip39.phrase(entropy, words)
assertEquals(
"letter advice cage absurd amount doctor acoustic avoid letter " +
"advice cage absurd amount doctor acoustic avoid letter always",
phrase,
)
assertContentEquals(entropy, Bip39.entropy(phrase, words))
}
/**
* 🔴 **The passphrase silently names a different wallet.** That is BIP-39's
* design, and it is the sharpest edge in the whole standard — somebody who
* mistypes it does not get an error, they get an empty account.
*/
@Test
fun `a different BIP-39 passphrase derives a different seed`() {
val phrase = Bip39.phrase(ByteArray(16), words)
assertNotEquals(hex(Bip39.seed(phrase)), hex(Bip39.seed(phrase, "TREZOR")))
assertNotEquals(hex(Bip39.seed(phrase, "a")), hex(Bip39.seed(phrase, "A")))
}
// ---- BIP-39 refusals -------------------------------------------------
@Test
fun `a mistyped word is refused rather than resolved to a neighbour`() {
val phrase = Bip39.phrase(ByteArray(16), words).replace("about", "abut")
assertFailsWith<Bip39.Failure.UnknownWord> { Bip39.entropy(phrase, words) }
}
/**
* 🔴 The checksum's whole job: a phrase of real words in a plausible order
* that is not a phrase anybody generated.
*/
@Test
fun `a phrase that fails its checksum is refused`() {
val phrase = Bip39.phrase(ByteArray(16), words).replace("about", "abandon")
assertFailsWith<Bip39.Failure.ChecksumFailed> { Bip39.entropy(phrase, words) }
}
@Test
fun `a phrase of the wrong length is refused`() {
val short = Bip39.phrase(ByteArray(16), words).split(" ").drop(1).joinToString(" ")
assertFailsWith<Bip39.Failure.BadPhraseLength> { Bip39.entropy(short, words) }
}
@Test
fun `entropy of a size the standard does not define is refused`() {
assertFailsWith<Bip39.Failure.BadEntropySize> { Bip39.phrase(ByteArray(15), words) }
assertFailsWith<Bip39.Failure.BadEntropySize> { Bip39.phrase(ByteArray(64), words) }
}
/** ⚠️ Extra spacing is a typing artefact, not a different phrase. */
@Test
fun `surrounding and repeated whitespace does not change the phrase`() {
val phrase = Bip39.phrase(ByteArray(16), words)
assertContentEquals(
Bip39.entropy(phrase, words),
Bip39.entropy(" " + phrase.replace(" ", " ") + "\n", words),
)
}
// ---- Base58 ----------------------------------------------------------
/**
* 🔴 **The leading-zero rule, which is where money goes missing.** Base58 is
* base conversion of one integer, and an integer cannot hold a leading zero
* byte — the standard prepends one `1` per zero byte instead. An
* implementation using `BigInteger` loses them and encodes two different
* keys to one address.
*/
@Test
fun `leading zero bytes each become a one`() {
assertEquals("1", Base58.encode(byteArrayOf(0)))
assertEquals("11", Base58.encode(byteArrayOf(0, 0)))
assertEquals("1111", Base58.encode(byteArrayOf(0, 0, 0, 0)))
// And a zero byte in front of real data is not the same as without it.
assertNotEquals(
Base58.encode(byteArrayOf(1, 2, 3)),
Base58.encode(byteArrayOf(0, 1, 2, 3)),
)
}
@Test
fun `base58 round-trips, zeros included`() {
for (case in listOf(
byteArrayOf(),
byteArrayOf(0),
byteArrayOf(0, 0, 0),
byteArrayOf(1),
byteArrayOf(0, 1, 2, 3, 4, 5),
ByteArray(32) { it.toByte() },
ByteArray(32) { 0xff.toByte() },
ByteArray(32),
)) {
assertContentEquals(case, Base58.decode(Base58.encode(case)),
"round trip lost ${hex(case)}")
}
}
@Test
fun `the published Bitcoin vectors encode as stated`() {
assertEquals("2NEpo7TZRRrLZSi2U", Base58.encode("Hello World!".toByteArray()))
assertEquals(
"USm3fpXnKG5EUBx2ndxBDMPVciP5hGey2Jh4NDv6gmeo1LkMeiKrLJUUBk6Z",
Base58.encode("The quick brown fox jumps over the lazy dog.".toByteArray()),
)
}
/**
* ⚠️ A mistyped address must FAIL, not resolve to somebody else's. `0`,
* `O`, `I` and `l` are absent from the alphabet for exactly this reason.
*/
@Test
fun `a character outside the alphabet decodes to nothing`() {
assertNull(Base58.decode("0OIl"))
assertNull(Base58.decode("abc def"))
assertNull(Base58.decode("abc+def"))
}
// ---- SLIP-0010 vectors -----------------------------------------------
/**
* SLIP-0010's own ed25519 test vector one. The chain code is checked as
* well as the key: a derivation that gets the key right and the chain code
* wrong produces correct CHILDREN of nothing, and diverges one level down.
*/
@Test
fun `slip10 ed25519 vector one`() {
val seed = unhex("000102030405060708090a0b0c0d0e0f")
val master = HdKey.master(seed)
assertEquals(
"2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7",
hex(master.privateKey),
)
assertEquals(
"90046a93de5380a72b5e45010748567d5ea02bbf6522f979e05c0d8d8ca9fffb",
hex(master.chainCode),
)
val child = master.derive("m/0H")
assertEquals(
"68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3",
hex(child.privateKey),
)
assertEquals(
"8b59aa11380b624e81507a27fedda59fea6d0b779a778918a2fd3590e16e9c69",
hex(child.chainCode),
)
val deeper = master.derive("m/0H/1H/2H/2H/1000000000H")
assertEquals(
"8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793",
hex(deeper.privateKey),
)
}
/** SLIP-0010's ed25519 vector two, with a longer seed. */
@Test
fun `slip10 ed25519 vector two`() {
val seed = unhex(
"fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a2" +
"9f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"
)
val master = HdKey.master(seed)
assertEquals(
"171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012",
hex(master.privateKey),
)
val child = master.derive("m/0H/2147483647H/1H/2147483646H/2H")
assertEquals(
"551d333177df541ad876a60ea71f00447931c0a9da16f227c11ea080d7391b8d",
hex(child.privateKey),
)
}
/**
* 🔴 **A path without hardening is REFUSED, not silently hardened.**
* SLIP-0010 defines no non-hardened derivation for ed25519. Somebody
* writing `m/44` means non-hardened; quietly handing them a different key
* than every other wallet would give them an address nobody else can
* reproduce from the same phrase.
*/
@Test
fun `an unhardened path component is refused`() {
val master = HdKey.master(ByteArray(32) { 1 })
assertFailsWith<HdKey.Failure.Unhardened> { master.derive("m/44") }
assertFailsWith<HdKey.Failure.Unhardened> { master.derive("m/44H/501") }
}
/** ⚠️ All three hardening notations are accepted, since all three are written. */
@Test
fun `H and h and apostrophe all mean hardened`() {
val master = HdKey.master(ByteArray(32) { 1 })
assertEquals(master.derive("m/44H"), master.derive("m/44h"))
assertEquals(master.derive("m/44H"), master.derive("m/44'"))
}
@Test
fun `a malformed path is refused`() {
val master = HdKey.master(ByteArray(32) { 1 })
assertFailsWith<HdKey.Failure.MalformedPath> { master.derive("m/notanumberH") }
assertFailsWith<HdKey.Failure.MalformedPath> { master.derive("m/99999999999H") }
}
@Test
fun `a seed outside the standard's range is refused`() {
assertFailsWith<HdKey.Failure.SeedOutOfRange> { HdKey.master(ByteArray(15)) }
assertFailsWith<HdKey.Failure.SeedOutOfRange> { HdKey.master(ByteArray(65)) }
}
/**
* ⚠️ Derivation is a pure function of seed and path. Sounds obvious; it is
* the property a restored wallet depends on entirely.
*/
@Test
fun `the same phrase and path always give the same key`() {
val seed = Bip39.seed(Bip39.phrase(ByteArray(16), words))
assertEquals(
HdKey.master(seed, "m/44H/501H/0H/0H"),
HdKey.master(seed, "m/44H/501H/0H/0H"),
)
assertNotEquals(
HdKey.master(seed, "m/44H/501H/0H/0H"),
HdKey.master(seed, "m/44H/501H/1H/0H"),
)
}
/**
* The BIP-44 shape the wallet actually uses, end to end from a phrase.
* Not a published vector — the point is that the whole chain runs and that
* successive accounts differ, which is what "a fresh address per receive"
* rests on.
*/
@Test
fun `a phrase walks the wallet path and gives distinct keys per account`() {
val seed = Bip39.seed(Bip39.phrase(ByteArray(32), words))
val keys = (0..4).map { HdKey.master(seed, "m/44H/501H/${it}H/0H").privateKey }
assertEquals(5, keys.map { hex(it) }.toSet().size, "two accounts derived the same key")
assertTrue(keys.all { it.size == 32 })
}
}