Create, import and store a wallet (Android engine) #73

Merged
paulhitt merged 1 commits from feat/wallet-store into main 2026-09-04 20:49:40 +00:00
4 changed files with 295 additions and 1 deletions
+20 -1
View File
@@ -341,7 +341,7 @@ the messenger rather than staying separate apps.
| Share a file into a chat or group | ⚪ |
| Wallet engine: BIP-39, Base58, SLIP-0010 | ✅ pinned to the published vectors |
| Wallet engine: address derivation and signing | ✅ BIP-44 path, Base58 address, and the engine's own RFC 8032 signs — no API-33 floor |
| Wallet: BIP-39 seed, create and restore | ⚪ |
| Wallet: BIP-39 seed, create and restore | 🟡 engine done — create, import, restore, sealed at rest; no screen yet |
| Wallet: fresh receive address per receive | ⚪ |
| Wallet: balance and transactions from a node | ⚪ |
@@ -379,6 +379,25 @@ only from API 33 — but the engine carries its own RFC 8032 (`Rfc8032`), so
`HdAccount.signingSeed` produces a key that signs on any supported device. The
wallet is not blocked on a platform floor.
🔴 **A wallet is not derivable from the account, and that is asserted rather
than intended.** 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.
⚠️ **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 elsewhere.
Self-custody in name only.
⚠️ **Erasing the device destroys the record; the phrase is then the only copy.**
Plain sign-out keeps it, and so does a password change (which signs out
plainly) — `signOut(erasingDevice = true)` and `deleteAccount` do not. 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.
⚠️ **A Solana address has no checksum, and `looksLikeAddress` says "well
formed", never "correct".** A single mistyped character that still decodes to 32
bytes is a perfectly valid address nobody holds the key for; money sent there is
@@ -1667,6 +1667,59 @@ class Vault(private val storage: Storage, private val key: SecretKey) {
storage.delete(pinnedName(threadId))
}
// -- Wallet -------------------------------------------------------------
private val walletName = "wallet-record"
/**
* The wallet's ENTROPY, sealed at rest, or null if there is no wallet.
*
* 🔴 **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 the phrase down again or move
* to another wallet. Self-custody in name only. Keeping the entropy means
* the phrase can always be regenerated from the record.
*
* ⚠️ **Sealed by the vault, which means erasing this device destroys it.**
* Plain sign-out keeps it (and so does a password change, which signs out
* plainly); "erase this phone" and "delete account" do not. After those, the
* recovery phrase is the only copy that exists — which is the whole point of
* a recovery phrase, and has to be SAID rather than discovered.
*/
fun walletEntropy(): ByteArray? =
readJson(walletName)?.optString("entropy")?.takeIf { it.isNotEmpty() }
?.let { runCatching { Bytes.unb64(it) }.getOrNull() }
fun saveWalletEntropy(entropy: ByteArray) {
val existing = readJson(walletName)
writeJson(walletName, JSONObject()
.put("entropy", Bytes.b64(entropy))
// ⚠️ The counter is carried across a rewrite rather than reset. A
// restore that zeroed it would hand out addresses somebody has
// already been paid at, undoing the one linkability the wallet can
// prevent.
.put("next", existing?.optInt("next", 0) ?: 0))
}
/** How many receive addresses have been handed out. */
fun walletNextIndex(): Int = readJson(walletName)?.optInt("next", 0) ?: 0
/**
* Advance the receive counter.
*
* ⚠️ Never goes backwards, for the reason above: an index that repeated
* would show an address that has already been used.
*/
fun saveWalletNextIndex(next: Int) {
val record = readJson(walletName) ?: return
if (next <= record.optInt("next", 0)) return
writeJson(walletName, record.put("next", next))
}
/** Forget the wallet entirely. The phrase is then the only copy. */
fun forgetWallet() = storage.delete(walletName)
// -- Home-record rollback floor (§20.4) ----------------------------------
private fun homeSerialName(fnid: String) = "home-serial-${Bytes.b64(Bytes.sha256(fnid.toByteArray(Charsets.UTF_8))).take(22).replace('/', '_')}"
@@ -0,0 +1,75 @@
package io.flamenet.e2e
import java.security.SecureRandom
/**
* Creating, importing and reading back a wallet.
*
* 🔴 **Addresses are independent of who you are, by construction.** The entropy
* is fresh randomness — never derived from the passphrase, the handle, or the
* Flamenet identity — so no address can be computed from an account and no
* account from an address. Deriving a wallet from the account 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. It is the
* one shortcut this file exists to refuse.
*
* ⚠️ **The phrase is the only copy that survives erasing the device.** The
* record is sealed by the vault, so plain sign-out keeps it and a password
* change keeps it — but "erase this phone" and "delete account" destroy it.
* That is what a recovery phrase is for, and it has to be said out loud at the
* moment the phrase is shown, not discovered afterwards.
*/
object Wallet {
/**
* A new wallet: the phrase to write down, and the entropy to store.
*
* ⚠️ 256 bits by default — 24 words. The extra length costs nothing to
* store and is what people expect from a wallet; 128 bits stays available
* for anybody who wants 12.
*
* 🔴 `SecureRandom()` with no seeding call. Seeding it from anything —
* a timestamp, a device id, the account — would make the wallet
* reconstructible from things that are not secret, which is the same
* failure as deriving it from the passphrase.
*/
fun create(words: List<String>, entropyBytes: Int = 32, random: SecureRandom = SecureRandom()):
Created {
val entropy = ByteArray(entropyBytes).also(random::nextBytes)
return Created(phrase = Bip39.phrase(entropy, words), entropy = entropy)
}
data class Created(val phrase: String, val entropy: ByteArray) {
override fun equals(other: Any?) = other is Created &&
phrase == other.phrase && entropy.contentEquals(other.entropy)
override fun hashCode() = 31 * phrase.hashCode() + entropy.contentHashCode()
}
/**
* Adopt a phrase that came from somewhere else.
*
* ⚠️ **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.
*/
fun import(phrase: String, words: List<String>): ByteArray =
Bip39.entropy(phrase, words)
/** The phrase again, from stored entropy — for showing it a second time. */
fun phrase(entropy: ByteArray, words: List<String>): String =
Bip39.phrase(entropy, words)
/**
* The 64-byte seed everything derives from.
*
* ⚠️ [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(entropy: ByteArray, words: List<String>, passphrase: String = ""): ByteArray =
Bip39.seed(phrase(entropy, words), passphrase)
/** The account this wallet's addresses come from. */
fun account(entropy: ByteArray, words: List<String>, nextIndex: Long = 0): HdAccount =
HdAccount(seed(entropy, words), nextIndex = nextIndex)
}
@@ -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) })
}
}