Create, import and store a wallet (Android engine)
`Wallet` makes one, adopts one from a phrase somebody typed, and reads the phrase back; the vault seals the record at rest. 🔴 The property guarded hardest is a NEGATIVE one: a wallet must not be derivable from the account. The entropy is fresh randomness — never the passphrase, the handle, or the Flamenet identity. Deriving it from the passphrase would be convenient, would need no backup at all, and would tie every payment somebody ever received to their handle on a public ledger for ever. The chain does not forget, so there is no undoing it later. A test asserts that eight wallets made in a row share nothing, which is what would catch that shortcut being taken. 🔴 The record stores the ENTROPY, never the seed. The seed is a one-way function of the phrase, so a record holding it would open the wallet and be unable to produce the words: somebody who lost their phrase but still had their account could spend, and could never write it down again or move to another wallet. Self-custody in name only. ⚠️ Erasing the device destroys the record, and the phrase is then the only copy that exists. Checked rather than assumed while writing this: plain sign-out keeps the vault and `updatePassword` uses the plain door, so a password change does NOT cost somebody their wallet; `signOut(erasingDevice = true)` and `deleteAccount` destroy it. That is what a recovery phrase is for, and it has to be said at the moment the phrase is shown rather than discovered afterwards. ⚠️ An imported phrase is validated BEFORE it is stored. One that fails its checksum is refused at import rather than saved and discovered later, when the wallet it names turns out to be empty and nobody can say why. ⚠️ The receive counter is carried across a rewrite rather than reset. Resuming at zero would hand out addresses somebody has already been paid at, undoing the one linkability a wallet can prevent. ⚠️ `SecureRandom()` with no seeding call. Seeding it from a timestamp or a device id would make the wallet reconstructible from things that are not secret — the same failure as deriving it from the passphrase, wearing a different hat. 9 tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e8c6776e2
commit
227c098da7
@@ -0,0 +1,147 @@
|
||||
package io.flamenet.e2e
|
||||
|
||||
import java.security.SecureRandom
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Creating, importing and reading back a wallet.
|
||||
*
|
||||
* 🔴 **The property worth guarding hardest is a negative one: the wallet must
|
||||
* NOT be derivable from the account.** Deriving it from the passphrase would be
|
||||
* convenient, would need no backup at all, and would tie every payment somebody
|
||||
* ever received to their handle on a public ledger for ever. There is no way to
|
||||
* undo that later — the chain does not forget — so it is asserted rather than
|
||||
* left to good intentions.
|
||||
*/
|
||||
class WalletTest {
|
||||
|
||||
private val words by lazy { Bip39.englishWordlist() }
|
||||
|
||||
/** Deterministic randomness, so a create() can be asserted against. */
|
||||
private fun fixedRandom(fill: Byte) = object : SecureRandom() {
|
||||
override fun nextBytes(bytes: ByteArray) { bytes.fill(fill) }
|
||||
}
|
||||
|
||||
// ---- What a wallet is not --------------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 Two wallets made in a row share nothing. If `create` ever started
|
||||
* deriving from something about the account, this is what would catch it.
|
||||
*/
|
||||
@Test
|
||||
fun `every wallet created is a different one`() {
|
||||
val phrases = (1..8).map { Wallet.create(words).phrase }
|
||||
assertEquals(8, phrases.toSet().size, "create() is not producing fresh entropy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a created wallet is 24 words by default and can be 12`() {
|
||||
assertEquals(24, Wallet.create(words).phrase.split(" ").size)
|
||||
assertEquals(12, Wallet.create(words, entropyBytes = 16).phrase.split(" ").size)
|
||||
}
|
||||
|
||||
/** The entropy really is what the phrase encodes, not a parallel value. */
|
||||
@Test
|
||||
fun `the phrase and the stored entropy are the same secret`() {
|
||||
val made = Wallet.create(words, random = fixedRandom(0x2a))
|
||||
assertContentEquals(made.entropy, Bip39.entropy(made.phrase, words))
|
||||
assertEquals(made.phrase, Wallet.phrase(made.entropy, words))
|
||||
}
|
||||
|
||||
// ---- Importing -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* ⚠️ Validated before it is stored. A phrase that fails its checksum is
|
||||
* refused here rather than saved and discovered later, when the wallet it
|
||||
* names turns out to be empty and nobody can say why.
|
||||
*/
|
||||
@Test
|
||||
fun `a phrase that does not check out is refused at import`() {
|
||||
val real = Wallet.create(words).phrase
|
||||
val broken = real.split(" ").toMutableList().also { it[0] = it[1] }.joinToString(" ")
|
||||
assertFailsWith<Bip39.Failure.ChecksumFailed> { Wallet.import(broken, words) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phrase containing a word that is not on the list is refused`() {
|
||||
val real = Wallet.create(words).phrase.split(" ").toMutableList()
|
||||
real[3] = "flamenet"
|
||||
assertFailsWith<Bip39.Failure.UnknownWord> {
|
||||
Wallet.import(real.joinToString(" "), words)
|
||||
}
|
||||
}
|
||||
|
||||
/** 🔴 An imported phrase gives back exactly the wallet it names. */
|
||||
@Test
|
||||
fun `importing a phrase restores the same addresses`() {
|
||||
val made = Wallet.create(words)
|
||||
val imported = Wallet.import(made.phrase, words)
|
||||
assertContentEquals(made.entropy, imported)
|
||||
assertEquals(
|
||||
Wallet.account(made.entropy, words).receive(0).address,
|
||||
Wallet.account(imported, words).receive(0).address,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- The counter -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* ⚠️ A restored wallet resumes at the index it was left at, when one is
|
||||
* known. Resuming at zero would hand out addresses somebody has already
|
||||
* been paid at, undoing the one linkability the wallet can prevent.
|
||||
*/
|
||||
@Test
|
||||
fun `an account can resume from a stored index`() {
|
||||
val made = Wallet.create(words)
|
||||
val resumed = Wallet.account(made.entropy, words, nextIndex = 5)
|
||||
assertEquals(5L, resumed.nextIndex)
|
||||
val (receive, _) = resumed.next()
|
||||
assertEquals(
|
||||
Wallet.account(made.entropy, words).receive(5).address,
|
||||
receive.address,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- The BIP-39 passphrase -------------------------------------------
|
||||
|
||||
/**
|
||||
* 🔴 A BIP-39 passphrase silently names a DIFFERENT wallet. That is the
|
||||
* standard's design and the sharpest edge in it: somebody who mistypes one
|
||||
* does not get an error, they get an empty account.
|
||||
*/
|
||||
@Test
|
||||
fun `a BIP-39 passphrase produces a different wallet from the same phrase`() {
|
||||
val made = Wallet.create(words)
|
||||
val plain = HdAccount(Wallet.seed(made.entropy, words)).receive(0).address
|
||||
val guarded = HdAccount(Wallet.seed(made.entropy, words, "extra")).receive(0).address
|
||||
assertNotEquals(plain, guarded)
|
||||
}
|
||||
|
||||
// ---- End to end ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The whole promise, in one test: write these words down, and any
|
||||
* implementation anywhere gives back these addresses.
|
||||
*/
|
||||
@Test
|
||||
fun `a phrase written down restores the same wallet`() {
|
||||
val made = Wallet.create(words, random = fixedRandom(0x11))
|
||||
val addresses = (0L..3L).map {
|
||||
Wallet.account(made.entropy, words).receive(it).address
|
||||
}
|
||||
|
||||
// Somebody types the words into a fresh install.
|
||||
val restored = Wallet.import(made.phrase, words)
|
||||
val restoredAddresses = (0L..3L).map {
|
||||
Wallet.account(restored, words).receive(it).address
|
||||
}
|
||||
|
||||
assertEquals(addresses, restoredAddresses)
|
||||
assertTrue(addresses.all { HdAccount.looksLikeAddress(it) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user