Create, import and store a wallet (Android engine) #73
@@ -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) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user