Skip to content

Encrypt the wallet file with a password derived from the mnemonic - #16

Open
peachbits wants to merge 4 commits into
mainfrom
wallet-file-password
Open

Encrypt the wallet file with a password derived from the mnemonic#16
peachbits wants to merge 4 commits into
mainfrom
wallet-file-password

Conversation

@peachbits

@peachbits peachbits commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes the wallet-file half of Zano wallet file passphrase (Kimi K3 F8a, High / conditional Critical), relocated here from EdgeApp/edge-currency-accountbased#1082 so the library that owns the wallet-file API owns its encryption. The accountbased PR shrinks to offline mnemonic utilities.

The problem

startWallet used its one seedPassword argument for two unrelated roles: decrypting the seed phrase, and encrypting the wallet file on disk. The seed passphrase is the empty string for every wallet created in-app and every import that does not set one, so those files — which hold the seed and spend keys — were effectively unencrypted. On iOS they live in the documents directory, which reaches iCloud/iTunes backups.

The fix

The file password is now derived from the mnemonic: domain-separated SHA-512, first 16 bytes as 32 hex characters. 32 chars is deliberate — Zano caps wallet-file passwords at 40 over a restricted alphabet (PASSWORD_REGEXP, enforced inside wallet2::generate), so a longer password would pass restore/open and then fail the moment anything called generate. The derivation is pinned by a golden-vector test; changing it orphans every file already encrypted with it.

Migration happens inside startWallet, decided entirely by what the file does (idempotent, self-healing, no persisted state):

  1. File absent → restore with the derived file password and the real seed password, now distinct.
  2. Opens with the derived password → done.
  3. WRONG_PASSWORD → try the legacy passwords (seedPassword, then ''), and on a hit: resetWalletPasswordcloseWallet → reopen to verify.
  4. Anything unrecoverable → delete and rebuild from the mnemonic, costing one re-scan.

Signature is backward-compatible (new optional log callback only), and thrown errors keep the exact historical <code> <message> shape — accountbased's error.message.includes('ALREADY_EXISTS') recovery is pinned by a test, trailing space included.

Why the sequence is shaped this way

Verified against the pinned SDK (zano_native_lib 239d4a39 → Zano d6be0ecf), each of these silently corrupts wallets if ignored:

  • reset_wallet_password only assigns the in-memory password (wallet2.cpp:3196). The file is re-encrypted when the wallet next stores, which closing does — so the close result is checked and the migration only believed after a verify-reopen.
  • closeWallet, not stopWallet: the async 'close' path discards close_wallet's return code and always reports OK (plain_wallet_api.cpp:779-785), so it cannot confirm the file was written.
  • Never re-key via store(path, password): it encrypts the keys blob with the argument but the body with m_password (wallet2.cpp:3500 vs :3524), producing a file that opens, fails to deserialize its body, wipes history, and silently re-scans. There is a comment against it in the code.

Also fixed while here: the catch-all macros report failure as success-shaped payloads ({result:{return_code:"INTERNAL_ERROR ..."}}, or UNINITIALIZED before init), which previously resolved into a WalletDetails with an undefined wallet_id. And generateSeedPhrase no longer leaves its side-effect wallet file on disk — the caller only wants the seed, and the first startWallet recreates the file properly via restore.

Testing

New mocha suite (npm test, first test infrastructure in this repo): 21 tests running the real CppBridge against a fake native module at the callZano string-protocol level. The fake mirrors the semantics that matter — resetWalletPassword mutates only in-memory state, and closing is what persists it — so dropping the close-to-persist step fails the suite. Covered: fresh restore with distinct passwords, re-key from '' and from a real passphrase (exact call sequence asserted), idempotence, failed reset, failed close, no-known-password rebuild, ALREADY_EXISTS rethrow with message-shape pin, success-shaped error payloads, and the derivation golden vector.

Pure TypeScript — no native code changes. The .cpp/binary layer is untouched; resetWalletPassword was already wired natively.

Release notes for whoever publishes

This should ship as 0.4.0 (behavior change: files get re-keyed on open). Both consumers currently declare ^0.3.0, which does not admit 0.4.0 — accountbased and the GUI bump their pins at merge time, per the usual stacked-PR flow. prepack runs the full native rebuild; before npm publish, verify the packed tarball contains all four Android .so files and the iOS xcframework, with sizes comparable to 0.3.0 — two past releases shipped with bad binaries.

Not yet done, required before shipping: the on-device QA pass, in particular sending from a migrated wallet, which is the only real proof the re-key preserved the spend key.


Note

High Risk
Changes wallet file encryption, on-disk migration, and backup exclusion for seed-bearing paths; mistakes could corrupt wallets, leak keys via backups, or mis-handle passphrases.

Overview
Wallet files are no longer encrypted with the seed passphrase (often empty). deriveWalletFilePassword hashes the normalized mnemonic into a stable 32-character file password; startWallet uses it for restore/open while keeping the seed passphrase separate.

startWallet now migrates existing files: open with the derived password, fall back to legacy passwords, re-key via resetWalletPassword + closeWallet with verify-reopen, or delete/rebuild when safe. Wrong seed passphrases and concurrent ALREADY_EXISTS cases throw instead of overwriting. An optional log callback reports recovery and re-key events.

ZanoError carries native return codes; handleRpcResponse throws on error payloads and non-OK return_code values so callers do not get wallets with undefined wallet_id. generateSeedPhrase deletes the side-effect wallet file after closing.

On iOS, ZanoModule pre-creates wallets, logs, and app_config and marks them excluded from backups. update-sources checks SDK folder #defines against that list; submodule update uses --force for repeatable prepack.

Adds npm test (mocha + fake native module), tweetnacl for derivation, and tsconfig.eslint.json for lint/verify over tests and scripts.

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


@peachbits
peachbits force-pushed the wallet-file-password branch from 87b3eb0 to 6e960d4 Compare August 6, 2026 21:28
@peachbits
peachbits changed the base branch from main to peachbits/zano-hf6-support-v2 August 6, 2026 21:28
@peachbits
peachbits force-pushed the wallet-file-password branch from e20c16b to cad72f2 Compare August 6, 2026 22:14
@peachbits
peachbits marked this pull request as ready for review August 7, 2026 17:07

@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.

Stale comment

{"review_module":"platform_pattern_reviewer","findings":[]}

Open in Web View Automation 

Sent by Cursor Security Agent: Security Reviewer

Comment thread src/CppBridge.ts
@peachbits
peachbits force-pushed the wallet-file-password branch from cad72f2 to 68e15cd Compare August 7, 2026 19:00
@j0ntz

j0ntz commented Aug 8, 2026

Copy link
Copy Markdown

Review pass over this PR. The migration logic is careful and the test suite is genuinely thorough, so most of this is about edge paths rather than the core design. I verified the derivation and the migration matrix by running the suite locally at this head (27 passing, npm install --ignore-scripts then mocha).

1. rebuild() trusts a delete the code itself says cannot be trusted (src/CppBridge.ts:371)

const rebuild = async (): Promise<WalletDetails> => {
  await this.deleteWallet(storagePath)
  return await restoreFresh()
}

The comment in generateSeedPhrase already states the native layer "reports OK regardless" of whether the delete succeeded. If the file survives, restoreFresh() calls restore() against a path that still has a file, which the fake models as ALREADY_EXISTS (test/fakeZanoModule.ts:84). Neither rebuild() call site catches it, so it propagates to edge-currency-accountbased, whose recovery is "adopt the already-open wallet by storage path". Nothing is open, so the lookup finds nothing and the wallet is stuck: no known password opens it, and every retry repeats the same failed rebuild.

The suite does not cover this because the fake's deleteWallet unconditionally succeeds (fakeZanoModule.ts:127-130). Suggest checking the delete result, or re-listing getWalletFiles() afterward and failing loudly if the path is still present.

2. The new SDK-folder tripwire cannot actually fail the build (scripts/update-sources.ts)

checkSdkFolders() is documented as "Fails the build if the SDK declares a directory we do not handle", and it throws. But the file ends with:

main().catch(error => console.log(error))

The throw is caught, logged, and the process exits 0. Since update-sources is the prepack script, a tripped check (or any fetch/build failure) still produces a "successful" npm pack / npm publish against a stale ios/ZanoModule.xcframework. That line is pre-existing rather than introduced here, but this PR is what gives it something to lose. One-line fix:

main().catch(error => {
  console.error(error)
  process.exitCode = 1
})

3. A transient verify-reopen discards a wallet file that was already migrated (src/CppBridge.ts:437)

In the legacy-password loop, once resetWalletPassword and closeWallet both succeed the file is genuinely re-keyed and held is false. If only the confirming openWith(filePassword) throws something other than ALREADY_EXISTS, the catch skips the held branch and falls straight into rebuild(), deleting a correctly-migrated file and forcing a full rescan. Consider retrying the verify open once, or treating a failed verify as non-fatal given the file is already correct. Only the ALREADY_EXISTS branch has coverage.

4. Rebuilding on "no known password" is a silent history wipe (src/CppBridge.ts:472)

The docstring calls this out, so this is a design question rather than a defect. Reaching this branch means something unexpected happened: a corrupt file, an unknown password scheme, or a native error mapped to WRONG_PASSWORD for a non-password reason. Funds are safe since the wallet is deterministic, but transaction history and labels go, and the caller gets a successful result with only a log line. Worth considering whether this branch should throw and let the caller decide rather than rebuild unprompted.

5. Em-dash in CHANGELOG.md:14

The - security: startWallet now encrypts... entry uses U+2014. It is the only one across the four Zano PRs, and the very next line uses --. A comma reads the same. Full ruleset: https://github.com/EdgeApp/edge-dev-agents/blob/main/.cursor/skills/no-slop/SKILL.md

6. Question rather than a finding: getOpenedWallets returns the wallet-file password

wallets_manager::get_opened_wallets sets owr.pass = ...get_wallet_password(), so the response carries the plaintext file password for every open wallet. WalletDetails does not declare pass, but the JSON still crosses the bridge. Given this PR exists specifically to protect that password, worth confirming nothing downstream logs raw RPC responses. edge-currency-accountbased#1082 adds a getOpenedWallets call on every view-key request.

7. Nothing runs the new test suite

This repo has no .github/workflows, so the mocha suite added here never runs on a PR. It passes today, so turning it on is cheap. Note a plain npm ci triggers prepare (husky + lint-staged + tsc) and prepack (update-sources, a full native build), so a test job wants --ignore-scripts.

Comment thread src/CppBridge.ts
@peachbits
peachbits force-pushed the peachbits/zano-hf6-support-v2 branch from 05ea428 to 0c3390e Compare August 10, 2026 19:15
@peachbits
peachbits force-pushed the wallet-file-password branch from 01e398f to 1afe40c Compare August 10, 2026 19:16
@peachbits
peachbits force-pushed the peachbits/zano-hf6-support-v2 branch 2 times, most recently from 8130a02 to 56496f5 Compare August 10, 2026 19:57
@peachbits
peachbits force-pushed the wallet-file-password branch from 1afe40c to be35dad Compare August 10, 2026 19:57
@peachbits
peachbits force-pushed the peachbits/zano-hf6-support-v2 branch from 56496f5 to 9e77912 Compare August 10, 2026 21:52
@peachbits
peachbits force-pushed the wallet-file-password branch from be35dad to 5982fd2 Compare August 10, 2026 21:52
Comment thread src/CppBridge.ts
@peachbits
peachbits force-pushed the peachbits/zano-hf6-support-v2 branch from 9e77912 to 8070c84 Compare August 10, 2026 22:12
@peachbits
peachbits force-pushed the wallet-file-password branch 2 times, most recently from b53644a to c0a000c Compare August 10, 2026 22:29

@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 c0a000c. Configure here.

Comment thread src/CppBridge.ts
@peachbits
peachbits force-pushed the wallet-file-password branch from c0a000c to 6292d00 Compare August 10, 2026 22:42
@peachbits

Copy link
Copy Markdown
Contributor Author

As of 6292d00.

1. rebuild() trusts a delete the code itself says cannot be trusted. Fixed. It confirms with isWalletExist, a filesystem probe, rather than reading the getWalletFiles listing. Bugbot caught that my first attempt was still fail-open, since getWalletFiles is typed WalletFiles | {} and I treated a missing items field as proof the file was gone.

2. The SDK-folder tripwire cannot fail the build. Fixed on #15, since that PR is what rewrites the script. main().catch now sets process.exitCode = 1. This one had already cost me an afternoon before you raised it: the script no-opped on a missing ANDROID_HOME and exited 0.

3. A transient verify-reopen discards a migrated file. Fixed. Once the close has reported OK the file really is re-keyed, so only a wrong password on the confirming open means the migration missed. Anything else leaves the file alone.

4. Rebuilding on "no known password" is a silent history wipe. Kept, but narrowed, and the narrowing came out of this. Rebuilding restores from the mnemonic with seedPassword, so it only reproduces this wallet when that is the passphrase the file was written with. Opening the file with seedPassword proves that; opening it with '' does not, and 0.3.0 wrote '' for wallets it believed had none. Both the post-loop path and the re-key failure path now refuse to rebuild when those disagree, so a wrong passphrase can no longer delete an intact file or write a different wallet over it.

Detecting a weakly keyed file is unaffected and there is a test pinning it: a wallet that has a passphrase but whose 0.3.0 file was keyed with '' still re-keys in place, because both candidates are tried before the guard.

For wallets with no passphrase it still rebuilds behind a log line. That is your original point, and it is a deliberate call on our side.

5. Em-dash in the changelog. Fixed.

6. getOpenedWallets returns the wallet-file password. Confirmed and accepted for now. Nothing in the bridge logs raw RPC responses. Narrowing it would mean a new native method for the two accountbased callers, which we would rather not add here.

7. Nothing runs the new test suite. Still open. The repo has no .github/workflows at all. Agreed that it is cheap, and noted on the --ignore-scripts caveat, since a plain npm ci triggers prepack and a full native build.

Base automatically changed from peachbits/zano-hf6-support-v2 to main August 14, 2026 17:27
peachbits and others added 2 commits August 14, 2026 10:36
`startWallet` used its one `seedPassword` argument for two unrelated
roles: decrypting the seed phrase, and encrypting the wallet file on
disk. The seed passphrase is the empty string for most wallets, so their
files -- which hold the seed and spend keys -- were effectively
unencrypted, and on iOS they live in the documents directory and reach
device backups.

The file password is now derived from the mnemonic (domain-separated
SHA-512, 32 hex characters -- inside Zano's 40-character password limit,
which `wallet2::generate` enforces). Files written by earlier versions
are re-keyed in place on their first open: open with the legacy password,
`resetWalletPassword`, close to persist, and reopen to verify. A file no
known password opens is deleted and rebuilt from the mnemonic, costing
one re-scan. The migration is decided entirely by what the file does, so
it is idempotent and self-healing.

Three native-layer details shape the implementation:

- `reset_wallet_password` only assigns the in-memory password; closing
  the wallet is what re-encrypts the file, so the close result is checked
  and the migration verified by reopening.
- `closeWallet` is used rather than `stopWallet`, whose native path
  discards the close result and always reports OK.
- The catch-all macros report failure as success-shaped payloads
  (`INTERNAL_ERROR`, `UNINITIALIZED`), which previously resolved into a
  wallet with an undefined `wallet_id`. `handleRpcResponse` now rejects
  those, and errors are `ZanoError` instances carrying the parsed code
  while keeping the historical message shape.

`generateSeedPhrase` now deletes the wallet file it writes as a side
effect of generating a seed, and `startWallet` gains an optional `log`
callback so callers can surface migration events.

Everything is covered by a new mocha suite running the bridge against a
fake native module that mirrors the reset-then-close-to-persist
semantics.

Two failures are deliberately not treated as a reason to rebuild. An
`ALREADY_EXISTS` from the verifying reopen means another handle took the
wallet in the window after our close, and callers recover from that by
adopting the open wallet, so it is rethrown rather than answered by
deleting an already-migrated file. A close that cannot be confirmed leaves
the wallet open on the file, so the file is left alone -- still keyed with
the legacy password, and migrated again on the next start.

Two failure paths deliberately do not rebuild, because rebuilding costs a
full rescan and can loop. The native delete reports OK whether or not it
removed anything, so `rebuild` confirms the file is gone before restoring
-- restoring onto a survivor answers ALREADY_EXISTS, which callers recover
from by adopting an open wallet, and none is open, so every start would
rebuild again. And once the close has reported OK the file really is
re-keyed, so only a wrong password on the confirming open means the
migration missed; anything else leaves the file alone.

A file that no known password opens is only rebuilt when the wallet has no
seed passphrase. With one set, every password this package writes has
already been tried -- the derived one, the passphrase, and the empty string
0.3.0 used -- so the passphrase is the likeliest thing to be wrong, and
rebuilding would restore a different wallet from the same mnemonic over a
file that was intact. Zano's checksum rejects most wrong passphrases, so
that usually meant a deleted file and a failed restore; the rest of the
time it meant keys that are not the user's, opening cleanly ever after
because the file password comes from the mnemonic alone and cannot tell the
two apart.

Detecting a weakly-keyed file is unaffected: the passphrase and the empty
string are both tried before this point, so a wallet that has a passphrase
whose 0.3.0 file was keyed with '' still re-keys in place.

The post-delete check asks `isWalletExist` rather than reading
`getWalletFiles`, which can answer without an `items` field -- treating that
as proof the file was removed would restore onto a survivor, the retry loop
the check exists to stop.

The same rule governs the re-key failure path. Rebuilding restores from the
mnemonic with `seedPassword`, so it only reproduces this wallet when that is
the passphrase the file was written with. Opening the file with
`seedPassword` proves it; opening it with '' does not, and 0.3.0 wrote ''
for wallets it believed had none. When those disagree the file that just
opened is the user's wallet and the rebuild would not be, so it is left
alone.
The wallet files hold the seed and spend keys. They live in the app's
documents directory, so they reach device backups -- and a Finder backup
is unencrypted unless the user opts in. Encrypting the file (previous
commit) protects it at rest; this keeps it out of the backup entirely.

`init` derives three directories from the working directory we pass it --
`wallets`, `logs` and `app_config` -- and creates them on first use. Only
we can set the backup flag, and that needs the directory to exist, so
create all three up front and flag them there. The SDK is happy to find
them already present.

Nothing in the app writes to `app_config` today, since the plugin never
calls `setAppconfig`, but `init` creates it regardless and the flag costs
nothing.

Android needs no equivalent: it stores these in private app storage, and
the app sets `android:allowBackup="false"`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The directories to pre-create and exclude from backups (previous commit)
cannot be derived at build time, so the list is maintained by hand in
`ios/ZanoModule.mm`. Nothing made that obvious, and the omission that
prompted this -- `app_config`, which `init` has always created -- went
unnoticed precisely because it was silent.

A new SDK directory can only arrive with a `zano_native_lib` pin bump,
and `update-sources.ts` is what performs that bump, so it is the one
place that always sees the new source. Without a check there, a pin bump
reviews as a changed commit hash and nothing else: `tmp/` is gitignored,
so the SDK source never appears in a diff.

`checkSdkFolders` therefore runs right after the sources are downloaded,
reads the folder names the SDK declares, and throws if any is missing
from our list. It is a tripwire rather than a proof: it matches `#define`d
folder names, which is how the SDK has always declared them, but a path
composed inline would slip past.

The matching lives in `scripts/utils/sdkFolders.ts` so it can be unit
tested. `update-sources.ts` calls `main()` at module scope, so importing
it from a test kicks off a native build.

Verified against both the previous SDK pin and the current HF6 one: each
declares exactly `app_config`, `logs` and `wallets`, and injecting a
fourth into the real source makes the check fail.
@peachbits
peachbits force-pushed the wallet-file-password branch from 6292d00 to e209928 Compare August 14, 2026 17:36
`update-sources` patches `RIPEMD160.h` to rename a function that collides
with Zlib, and that file lives inside the `Zano` submodule. `git submodule
update` refuses to move a submodule across a commit that changes a locally
modified file, so once the patch is in place the next run aborts with
"Your local changes would be overwritten by checkout" and takes the whole
script with it.

That happens whenever the submodule has to move and `RIPEMD160.h` differs
across the move, which is exactly what an SDK pin bump does, and what a run
interrupted mid-move leaves behind. `prepack` runs this script, so a build
that cannot repeat is a publish that fails on the second attempt.

`--force` discards the patch before the checkout, and `downloadSources`
re-applies it immediately afterwards, so the build is unchanged and now
repeatable. Reproduced both directions against the real submodule: without
the flag the update exits 1 on a commit pair where the file differs, with
it the update lands on the pinned commit and the working tree is clean.
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