Skip to content

Zano: offline seed phrase generation and validation - #1082

Open
peachbits wants to merge 7 commits into
masterfrom
zano-wallet-file-password
Open

Zano: offline seed phrase generation and validation#1082
peachbits wants to merge 7 commits into
masterfrom
zano-wallet-file-password

Conversation

@peachbits

@peachbits peachbits commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

Description

Part of Zano wallet file passphrase (Kimi K3 F8a). Scope note: this PR was originally the whole fix, including the engine-side wallet-file re-key migration. That migration now lives inside react-native-zano's startWallet (EdgeApp/react-native-zano#16), where the library that owns the wallet-file API owns its encryption — so this PR shrank to the accountbased-side work: generating and validating seed phrases offline.

What changed

createPrivateKey obtained a seed phrase by calling generateSeedPhrase, which writes a wallet file to disk as a side effect and can only encrypt it with the seed passphrase — the empty string for every wallet created in the app. It now generates from io.random(32) through @zano-project/zano-utils-js, so no wallet file exists until the engine creates one — by which point react-native-zano encrypts it with a derived password.

A generated phrase is the user's only backup, so it is self-checked twice before being returned: against the checksum rules, and against the native library's own address derivation (getSeedPhraseInfo runs on instance 0 — no wallet opened, no file written).

importPrivateKey and derivePublicKey validate and derive offline when no passphrase is involved — the common case, and the one parseUri hits for every scanned payload. Passphrase-protected phrases still use the native library, the only implementation that supports them: the pure-JS library has no crypt_with_pass, and Zano core asserts the phrase's password flag matches the supplied password, so decoding a protected phrase without it yields a wrong address rather than an error. The helpers detect that flag and throw instead.

Imported phrases are stored normalized (trimmed, single-spaced), since react-native-zano derives the wallet-file password from the stored phrase.

The engine gains one line of defense: after startWallet, the native wallet's address must match the stored publicKey, failing startup loudly rather than syncing a wallet whose address is not the one shown to the user. No-op for existing wallets (their keys were derived natively); a hard guard for everything created or swept after this change.

Deliberate choices

  • Import does not reject on a checksum mismatch. The native get_seed_phrase_info path never enforced it, and scanned v2 full seeds embed whatever checksum word their payload carries — rejecting would break imports that work today. Pinned by a test whose fixture is exactly such a phrase.
  • The zano-utils-js pin bump (v0.0.4-edge.2) fixes a real bug already reachable in production: ~1 in 814 generated phrases came out as 25 words plus a trailing space, because the JS library was missing Zano core's checksum wrap-around clamp. A guard test keeps the pin from regressing.

Testing

520 unit tests pass. New coverage: the offline helpers (golden-vector address derivation, passphrase-flag detection, checksum boundary), and ZanoTools key operations against a recording fake bridge — including the security regression test that createPrivateKey performs no file-writing native calls, and that no-passphrase import/derive make zero native calls.

Device QA happens on the combined build with react-native-zano#16; the checklist lives on that PR.


Note

High Risk
Changes wallet creation, seed backup, address derivation, and startup guards—any bug could produce unrecoverable or mismatched wallets; mitigated by self-checks, engine address verification, and broad tests.

Overview
Zano wallet creation and key handling no longer depend on the native SDK for the common (no seed passphrase) path. New pure-JS helpers in zanoMnemonic.ts normalize phrases, derive addresses, validate checksums, and self-check generated entropy.

createPrivateKey builds 26-word phrases from io.random via @zano-project/zano-utils-js with round-trip and checksum checks—no generateSeedPhrase and no wallet file on disk at create time. importPrivateKey / derivePublicKey validate and derive offline without a passphrase; passphrase-protected seeds still go through getSeedPhraseInfo. Imported mnemonics are stored normalized.

ZanoEngine fails startup if the native wallet address does not match stored publicKey (including when adopting an ALREADY_EXISTS wallet), closes mismatched handles to avoid leaks, forwards wallet-file migration logs from startWallet, and reads the private view key via getOpenedWallets instead of blocking getWalletInfo.

Pins @zano-project/zano-utils-js v0.0.4-edge.2 (checksum wrap-around fix) and bumps react-native-zano to ^0.3.0 for peer alignment. New unit tests cover mnemonic helpers, key flows, and the checksum regression guard.

Reviewed by Cursor Bugbot for commit ec25287. Bugbot is set up for automated code reviews on this repo. Configure here.


@peachbits
peachbits force-pushed the zano-wallet-file-password branch from 493692c to 51f1111 Compare August 5, 2026 19:43
@peachbits
peachbits force-pushed the zano-wallet-file-password branch from 51f1111 to 174f047 Compare August 6, 2026 19:51
@peachbits peachbits changed the title Zano: encrypt the native wallet file with a derived password Zano: offline seed phrase generation and validation Aug 6, 2026
@peachbits
peachbits marked this pull request as ready for review August 7, 2026 17:07
Comment thread src/zano/ZanoEngine.ts
@peachbits
peachbits marked this pull request as draft August 7, 2026 17:17
@peachbits
peachbits force-pushed the zano-wallet-file-password branch from 6d4b60a to 1334847 Compare August 7, 2026 17:46
The previous build emitted a 25-word seed phrase with a trailing space
instead of 26 words whenever the checksum landed on CHECKSUM_MAX, about
one seed in 814. That path is already reachable from `parseUri`, which
decodes scanned base58 raw seeds through `seedToMnemonic`.

The new build also exports `verifySeedPhrase` and
`isSeedPhrasePasswordProtected`, so seed phrases can be validated without
calling into the native Zano library.

Add a test that guards the pin, since downgrading it would silently hand
users malformed backup phrases again.
@peachbits
peachbits force-pushed the zano-wallet-file-password branch 2 times, most recently from 1a81ab9 to 8871cf1 Compare August 7, 2026 19:08
@peachbits
peachbits marked this pull request as ready for review August 7, 2026 19:24
@j0ntz

j0ntz commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review pass over the move to offline seed generation and validation.

1. Dropping the checksum result is a regression, and the comment justifying it does not hold

validateMnemonic (src/zano/zanoMnemonic.ts:98) calls verifySeedPhrase(normalized) and discards the boolean. The comment says:

Zano core skips checksum verification for 25-word phrases and the native get_seed_phrase_info path never enforced it

The first half is right. The second half is not, for 26-word phrases. Traced through the SDK:

  • The old importPrivateKey threw Unable to validate mnemonic when seedPhraseInfo.response_data.address === ''.
  • tools::get_seed_phrase_info (src/wallet/wallet_helpers.h:122-128), passphrase-less branch: result.syntax_correct = acc.restore_from_seed_phrase(seed_phrase, ""); if (result.syntax_correct) { result.address = acc.get_public_address_str(); }. A false return leaves address empty.
  • account_base::restore_from_seed_phrase (src/currency_core/account.cpp:226-229) returns false on a checksum mismatch: LOG_PRINT_L0("seed phase has invalid checksum: ..."); return false;

So a 26-word phrase with a wrong checksum word used to be rejected at import and now imports silently, deriving an address for the wrong account. That is the realistic transcription error: one word mistyped into another word that also happens to be in the dictionary.

Honoring the return value cannot break 25-word imports, because verifySeedPhrase returns true early when there is no checksum word (seed-phrase-info.ts:104-106). The "scanned v2 full seeds carry an arbitrary checksum word" concern also does not argue for ignoring it, since native rejected those too.

Suggest if (!verifySeedPhrase(normalized)) throw new Error('Zano seed phrase checksum does not match'), plus a test, since the new suite has no bad-checksum import case.

2. The new address guard leaks a native handle and disables itself (src/zano/ZanoEngine.ts:118 and :159)

if (response.wi.address !== this.walletInfo.keys.publicKey) {
  throw new Error('initializeWallet: native wallet address does not match the wallet public key')
}

This throws after startWallet already opened the native wallet, and nothing closes it. makeLifecycleManager does not call onStop for a thrown onStart, so the handle stays open and wallet_id is discarded. On the next tick onStart runs again, startWallet hits ALREADY_EXISTS, and the catch block adopts the wallet by storage path and returns its id at line 159 with no address check at all. From then on the engine syncs the mismatched wallet exactly as it did before this PR, and each restart leaks another handle.

Suggest closing the wallet before throwing, and applying the same address check to existingWallet in the recovery path.

3. derivePublicKey's two branches now fail differently (src/zano/ZanoTools.ts:199)

The offline branch throws on an unparseable mnemonic. The passphrase branch still returns response_data.address without checking error_code or an empty address, which is the check importPrivateKey performs on the identical call a few lines above. A wrong passphrase therefore yields { publicKey: '' } rather than a clear failure. Worth making the two branches agree.

4. JSON.stringify(error) erases the message (src/zano/ZanoEngine.ts:436)

throw new Error('Failed to get wallet info: ' + JSON.stringify(error))

JSON.stringify on an Error yields {}, so every failure inside getDisplayPublicSeed reports Failed to get wallet info: {}. String(error) or error.message keeps the cause. Pre-existing, but the block's contents changed here.

5. The view-key change is well justified, with one caveat (src/zano/ZanoEngine.ts:423)

The comment's claim checks out against the SDK: wallets_manager::get_wallet_info takes wo.w.lock(), the blocking per-wallet lock, while get_opened_wallets uses unlocked_get() plus get_wallet_info_unlocked and only a shared lock on the wallets map. Confirmed at wallets_manager.cpp:1217-1233 and :2030-2043.

The behavior change worth naming: the old call waited on the lock, the new one fails immediately with The wallet is not among the opened wallets if the wallet is missing from the snapshot. That is the intended trade, but the message will read as a bug report when it is really a race. Consider naming the wallet id in it.

This block also duplicates the getOpenedWallets shape-check-and-find already inlined in onStart's recovery path; one helper would cover both.

6. The tests cannot catch a JS versus native divergence (test/zano/ZanoToolsKeys.test.ts:32)

The fake's getSeedPhraseInfo returns deriveAddressFromMnemonic(mnemonic), which is the JS implementation under test. So the "native" answer is the JS answer, and the suite can never detect the two derivations disagreeing, which is precisely what the new runtime guard exists to catch. The opts.address override covers the guard's reaction, not the agreement itself. Only a device or sim run, or a vector captured from the native library, establishes that.

I fuzzed the JS half in isolation against zano-utils-js#1: 3000 random 32-byte seeds through base16.stringify then seedToMnemonic then mnemonicToSeed(full) then verifySeedPhrase produced no malformed phrases, no round-trip mismatches, and no checksum failures, which supports the "roughly one in 814" changelog entry. It says nothing about native agreement.

7. Blocking: the PR cannot compile against the dependency it pins

Travis job 640334505 fails with src/zano/ZanoEngine.ts:114 TS2554 Expected 3 arguments, but got 4. package-lock.json:15637 resolves react-native-zano to published 0.3.0, whose startWallet takes 3 parameters; the 4-argument form with { log } only exists in EdgeApp/react-native-zano#16. So this PR both fails CI and, as pinned, would ship without the derived-wallet-file-password fix that is the point of the parent task. Ordering looks like #15, then #16, then publish, then bump here.

@peachbits
peachbits force-pushed the zano-wallet-file-password branch from 8871cf1 to 7d945e5 Compare August 10, 2026 19:00
Comment thread src/zano/ZanoEngine.ts
@peachbits
peachbits force-pushed the zano-wallet-file-password branch 2 times, most recently from cf708f0 to 8ec7f6b Compare August 10, 2026 19:56

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8ec7f6b. Configure here.

Comment thread src/zano/zanoMnemonic.ts
peachbits and others added 6 commits August 10, 2026 14:09
`deriveAddressFromMnemonic`, `validateMnemonic`, `normalizeMnemonic`,
`verifyMnemonicChecksum` and `mnemonicMatchesKeysSeed` derive, validate
and self-check Zano seed phrases in pure JS, with no native library
involved. Nothing calls them yet.

Only phrases without a seed passphrase can be handled this way: the JS
library cannot decrypt a protected seed, and Zano core asserts that the
phrase's password flag matches the password supplied to it, so a
protected phrase decoded without its passphrase yields a wrong address
rather than an error. The helpers detect that case and throw.

`validateMnemonic` enforces the checksum word on 26-word phrases, matching
the native path it replaces: `account_base::restore_from_seed_phrase`
returns false on a mismatch and `get_seed_phrase_info` then reports an
empty address, which callers threw on. Without it, one word mistyped onto
another dictionary word imports silently as a different account. 25-word
phrases carry no checksum word and are unaffected, which is what Zano core
does too.

Every helper here refuses a passphrase-protected phrase, and the module
says so. `verifySeedPhrase` throws on one by itself, but `mnemonicToSeed`
does not -- it answers with a seed derived without decrypting, so a
protected phrase produces a wrong key rather than an error. Guarding
`mnemonicMatchesKeysSeed` rather than trusting its callers keeps that from
reading as a plain mismatch. Protected phrases go to the native library
through `ZanoTools`, which is the only implementation that supports them.
… library

`createPrivateKey` obtained a seed phrase by calling
`generateSeedPhrase`, which writes a wallet file to disk as a side
effect and can only encrypt it with the seed passphrase -- the empty
string, for every wallet created in the app. Generate the phrase from
the plugin's own entropy instead, so no wallet file exists until the
engine creates one, and creating a wallet starts nothing native at all.

A generated phrase is the user's only backup, so it is self-checked
twice before being returned, both times offline: the words must decode
back to the entropy they were built from, and the checksum word must
match them. The round-trip covers the encoder, which is where the
25-word phrases came from.

There is deliberately no native cross-check. `ZanoEngine` compares the
native wallet's address against this phrase's derived one every time the
wallet starts, so a JS/native disagreement still stops the wallet before
it can sync, and it does so against the real wallet rather than
instance 0. Starting the SDK -- creating its working directories and log
file -- is not something wallet creation should be doing to re-derive an
address we already have.

`importPrivateKey` and `derivePublicKey` now validate and derive offline
when no passphrase is involved, which is the common case and the one
`parseUri` hits for every scanned payload. Phrases protected by a seed
passphrase still go through the native library, which is the only
implementation that supports them: `@zano-project/zano-utils-js` has no
decryption path at all, and `mnemonicToSeed` takes no password. Those
two branches keep their own `init` calls, which cannot be left to
`ZanoEngine` -- both run before an engine for the wallet exists.

Imported phrases are stored normalized, since react-native-zano derives
the wallet-file password from the stored phrase.

`derivePublicKey`'s passphrase branch checks the native response the same
way `importPrivateKey` does on the identical call. Without it a wrong
passphrase returned `{ publicKey: '' }` and the wallet was created with an
empty address instead of failing, while the offline branch alongside it
threw.
…blic key

The public key is now derived from the seed phrase in pure JS for
wallets without a passphrase, so check it against the address the native
wallet actually reports when it opens. A mismatch fails engine startup
loudly instead of syncing a wallet whose address is not the one shown to
the user.

Two things keep the check from being trivially bypassed. It closes the
wallet before throwing, since `startWallet` has already opened it and the
lifecycle manager does not run `onStop` for an `onStart` that threw -- left
open, the handle leaks and the next start finds it through ALREADY_EXISTS.
And the ALREADY_EXISTS recovery path applies the same comparison to the
wallet it adopts, so retrying cannot walk around the check.
The dev and peer ranges were `^0.2.7` while edge-react-gui installs
`^0.3.0`. Caret ranges below 1.0 do not widen past the minor version, so
those two do not overlap and the peer dependency was unsatisfiable --
visible only because both repos set `legacy-peer-deps`.
`react-native-zano`'s `startWallet` takes an optional `log` callback and
uses it to report what happened to the wallet file on disk: re-keyed with
the derived password, rebuilt because no known password opened it, or
recovered by the native library. We passed no callback, so all of that was
silent -- a wallet that lost its history to a rebuild looked identical to
one that migrated cleanly.

Route it to `this.log.warn` so the events land in the wallet log alongside
the rest of the engine's output.

Requires the unreleased `startWallet` overload from react-native-zano; the
dependency pin follows when that publishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`getDisplayPublicSeed` fetched the private view key through
`getWalletInfo`, which calls `wallets_manager::get_wallet_info` -- an
unbounded `lock()` on the per-wallet mutex, with no timeout. Its
neighbour `get_recent_transfers` uses `try_lock` and returns CORE_BUSY
instead, so the blocking behaviour here looks unintentional.

That matters because the GUI asks for this key for every wallet a few
seconds after login, as part of its compromised-key sweep. The native
module runs its calls on one serial queue, so a single wallet that is
mid-refresh holds the lock and every other Zano call queues behind it --
taking the app with it. Sampling a frozen app showed 100% of samples
parked in `plain_wallet::get_wallet_info` under `__psynch_mutexwait`.

`getOpenedWallets` returns the same secret without the lock: it takes
only the shared wallet-map lock and fills each entry with
`get_wallet_info_unlocked`. `wi.view_sec_key` and
`wi_extended.view_private_key` are both `pod_to_hex(view_secret_key)`.

Both calls read the same unfiltered `m_wallets` map, so a wallet we hold
an id for is always listed -- there is no case where the old call could
answer and this one could not. Rather than keep it as a fallback that
would silently reintroduce the lock, throw: if that ever fires, the
assumption above is wrong and we want to see it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

The catch reports `String(error)` rather than `JSON.stringify(error)`,
which yields `{}` for an Error and made every failure here read the same.

The list-and-find is now one `listOpenedWallets` helper, shared with the
ALREADY_EXISTS recovery path in `onStart` that was already doing the same
shape check. The failure names the wallet id, since reaching it usually
means the wallet closed or was still opening between the id being handed
out and this snapshot -- a race rather than the bug the old wording
suggested.
@peachbits
peachbits force-pushed the zano-wallet-file-password branch from 8ec7f6b to ec25287 Compare August 10, 2026 21:09
@peachbits

Copy link
Copy Markdown
Contributor Author

As of ec25287f.

1. Dropping the checksum result is a regression. Confirmed against the SDK and fixed. restore_from_seed_phrase returns false on a mismatch (account.cpp:226) and get_seed_phrase_info then reports an empty address (wallet_helpers.h:94), which the old importPrivateKey threw on. validateMnemonic honors the return value now, with the bad-checksum test you noted was missing. Your point that 25-word phrases cannot break is what makes it safe: verifySeedPhrase returns true for them before reaching the check.

Two existing tests asserted the old behavior and are inverted. One justified itself by claiming its phrase was the fixture used by zanoTools.parseUri.test.ts; it is not, that phrase appears nowhere else in the suite.

2. The address guard leaks a handle and disables itself. Confirmed and fixed. lifecycleManager.ts:108 only calls onStop when startup succeeded, so the wallet is closed before the throw, and the ALREADY_EXISTS recovery path applies the same address comparison to the wallet it adopts. The leak is what caused the bypass, so fixing either half alone would not have held.

3. derivePublicKey's two branches fail differently. Fixed. The passphrase branch checks error_code and an empty address the way importPrivateKey does on the identical call.

4. JSON.stringify(error) erases the message. Fixed, now String(error).

5. The view-key change, with one caveat. Both parts done. The failure names the wallet id and says what it means, that the wallet closed or was still opening between the id being handed out and the snapshot. The list-and-find is now one listOpenedWallets helper, shared with the recovery path in onStart that was already doing the same shape check.

6. The tests cannot catch a JS versus native divergence. Still true as written. The fake answers getSeedPhraseInfo with deriveAddressFromMnemonic, so the suite cannot see the two derivations disagree. What changed is the other half of the problem: zano-utils-js now carries known-answer vectors transcribed from Zano core's own tests/unit_tests/wallet_seed_test.cpp, two 25-word phrases plus a 26-word phrase and its auditable variant, each with the spend and view secret keys core derives. That pins the JS derivation to the reference implementation. Your point that only a device run or a captured native vector proves agreement stands, and that verification is still outstanding.

7. The PR cannot compile against the dependency it pins. Expected. The lockfile cannot point at #16 until it publishes, so this clears at merge time in the order you set out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants