Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010 #71
Merged
paulhitt
merged 3 commits from 2026-09-04 20:49:33 +00:00
feat/wallet-engine-primitives into main
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4ee0170dcc |
Wallet engine primitives in Kotlin: BIP-39, Base58, SLIP-0010
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> |
||
|
|
6e4eca84e3 |
Refuse a home record older than one already seen (§20.4, Android half)
Both remaining ⚪ protocol rows turned out to be stale. `AccountKdf.localAccountSalt` is pinned to the published vector and wired into sign-in for a local relay; `MailboxHoming` and `AccountIdentity.publishHomes` have shipped the publish and read halves for a while. PARITY.md was describing a gap that had closed, which is its own kind of wrong — a definition of done that overstates the work left is one people stop reading. 🔴 What was genuinely missing was on the READING side, and on BOTH clients. An older home record is GENUINELY SIGNED — the account holder published it, before they added a second relay. So a relay that wants to be somebody's only mailbox does not have to forge anything: it serves the record it saw first, that record verifies perfectly, and the sender delivers to one relay believing it is delivering to all of them. That is the attack multi-homing exists to survive, and the serial is what was added to stop it. The relay refuses a lower serial on WRITE. Nothing refused one on READ: `verifiedRelays` here and `verifyHomes` on iOS both checked the signature and stopped. Fixed on both — iOS in ios!116, this is the Kotlin half. The highest serial seen per address is remembered in the vault and a lower one is refused. Equal is fine; only strictly lower is a rollback. 🔴 The floor rises only for a record that VERIFIED, via a separate `verifiedSerial`. Otherwise a hostile relay could hand over an unsigned record carrying Int.MAX_VALUE, have the client raise its floor to it, and lock out every later genuine record — turning the rollback defence into a denial of service against the account holder. Tested. ⚠️ Sealed in the vault and keyed by a hash of the address: which addresses this device has looked up is exactly the metadata the vault exists to keep off a stolen phone. ⚠️ The refusal does not claim an attack. A relay that has genuinely fallen behind serves an old record for honest reasons, and nothing in a client can tell that from the other case. 8 unit tests. App builds, every JVM suite green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
455cdcd818 |
Edit, forward and pin from the conversation (§6.15/§6.16/§6.19)
The outbound half of the three controls whose codecs were already back. The long-press menu gains Edit, Pin/Unpin and Forward; the thread gains a pinned banner; a bubble says `edited` and `Forwarded`. 🔴 An edit that silently replaces the words is worse than the typo. The reader has no way to tell a correction from their own misremembering, and no way to know the message they replied to is not the message that is there now. So the bubble is marked, on both ends, and the edit sheet says plainly that everyone in the conversation sees the new wording and a mark on their copy. 🔴 A forward carries the words and NOTHING about where they came from — no author, no thread, no quote — and the test asserts the ABSENCE of a reference rather than only the presence of the flag. The obvious "improvement", carrying a reference so the reader can see the original, would republish a private message's author to somebody who was never in that conversation. It also gets a NEW message id: reusing the original's would make the copy a target for edits and reactions aimed at the original, one of which would silently rewrite the other. ⚠️ Only your own message can be edited, checked in `Session` and not only in the menu. The receiving side enforces the same rule independently, so neither end is trusting the other. Anyone may PIN, by contrast: a pin is a shared banner rather than a permission, and restricting it to the author would mean the useful message in a group is exactly the one nobody but its writer can pin. ⚠️ Edits and pins are applied locally first and rolled back if the send fails, the same shape `react` already uses. Waiting on a round trip to see your own correction appear makes it feel broken on a slow connection; leaving one that exists only on this phone is worse, because you would believe you had fixed it. ⚠️ A forward does not carry an attachment yet. Re-sending a blob means re-uploading it under a new token, and passing on the ORIGINAL token would hand the new recipient a capability to a file in a conversation they are not part of. Text only until that upload exists. ⚠️ The pinned banner resolves the id against the lines on screen, so a pinned message that was since deleted shows nothing rather than a stale copy. Clearing a thread clears its pin. Two things found while wiring it: `Content.text` has no `groupId` parameter, so a group forward needs `groupText` — a compile error rather than a silent drop. And the edit/forward state is hoisted above the message list, because a row's own `remember` dies when it scrolls out and an edit box vanishing mid-typing is the worst way to lose a correction. 9 more unit tests. App builds, every JVM suite green. The three PARITY rows are now ✅. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |