The foundation Files and Wallet rest on. PARITY.md was right that this is a build rather than a port of a screen — these had no Kotlin counterpart at all. 🔴 Pinned to the STANDARDS, not to the Swift engine. 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 this file guards 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. Two Kotlin-specific decisions, both money-relevant: 🔴 PBKDF2-HMAC-SHA512 is hand-written over `Mac` rather than taken from `SecretKeyFactory("PBKDF2WithHmacSHA512")`. The JCA path takes a CharArray and the provider decides how to encode it — 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 to different bytes under a different provider and derive a different, empty, perfectly valid wallet with no error anywhere. Taking the bytes ourselves removes the provider from the decision. (It is also absent below API 26.) 🔴 Base58 is the digit loop, not `BigInteger`. The obvious Kotlin version is `BigInteger(1, bytes).toString(58)` with a remapped alphabet, and it is wrong in the one way that matters: BigInteger discards leading zeros, so a key beginning 0x00 loses a character and encodes to another key's address. ⚠️ One test was WRONG on the first run and the implementation was right. The all-ones 24-word vector carried the seed from a different BIP-39 vector. It was resolved by computing the value independently — PBKDF2-HMAC-SHA512, NFKD, 2048 rounds — rather than by adopting whatever the implementation produced, and the file says so. A pinned value taken from the code under test pins the bug. ⚠️ The vendored word list is byte-identical to the iOS one and verified on every load, not once at build: it ships inside the app and a check that only ran in CI would not notice one that arrived corrupted. 23 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
14 KiB
Kotlin
348 lines
14 KiB
Kotlin
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 })
|
|
}
|
|
}
|