diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1ea559..bf324640e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- added: (Zano) Verify that the native wallet address matches the address derived from the seed phrase when a wallet starts, failing the start rather than syncing a wallet whose native address is not the one shown to the user. +- added: (Zano) Report wallet-file migration and recovery events to the wallet log, so a re-keyed or rebuilt wallet file is visible in support logs rather than silent. +- changed: (Zano) Generate seed phrases from the plugin's own entropy rather than through the native library. Creating a wallet no longer starts the native library or writes a wallet file to disk, and the generated phrase is self-checked offline: it must decode back to the entropy it was built from, and its checksum word must match. +- changed: (Zano) Derive addresses and validate seed phrases without the native library, so scanning or sweeping a Zano private key no longer needs the native module. Phrases protected by a seed passphrase still use the native library, which is the only implementation that supports them. +- fixed: (Zano) Roughly one in 814 newly created seed phrases came out with 25 words and a trailing space instead of 26 words, because the mnemonic library was missing a checksum wrap-around case that Zano core handles. +- fixed: (Zano) Align the `react-native-zano` dependency range with the app. The dev and peer ranges were `^0.2.7` while edge-react-gui installs `^0.3.0`, and caret ranges below 1.0 do not widen past the minor version, so the peer dependency was unsatisfiable. +- fixed: (Zano) Read the private view key without taking the native per-wallet lock. The app requests it for every wallet shortly after login, and the previous call blocks with no timeout, so an account with several Zano wallets could hang while one of them was mid-refresh. + ## 4.87.0 (2026-08-02) - added: (Sui) `rpcNodes`, `rpcNodesArchival`, and `maxRequestsPerSecond` to the info payload, so nodes can be changed without a client release. Transaction sweeps start on an archival node, since the walk begins at the wallet's oldest transaction and a pruned node rejects a cursor older than its retention window. diff --git a/package-lock.json b/package-lock.json index d4fbb346a..a092c0131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "@ton/core": "^0.59.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^15.1.0", - "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.1/zano-project-zano-utils-js-0.0.4.tgz", + "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.2/zano-project-zano-utils-js-0.0.4-edge.2.tgz", "@zondax/izari-filecoin": "^1.2.6", "algosdk": "^2.1.0", "assert-log": "^0.2.2", @@ -114,7 +114,7 @@ "querystring": "^0.2.1", "react-native-monero": "0.4.0", "react-native-piratechain": "0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", "shell-quote": "^1.8.1", @@ -132,7 +132,7 @@ "peerDependencies": { "react-native-monero": "^0.3.0", "react-native-piratechain": "v0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "^0.13.1" } }, @@ -6000,9 +6000,9 @@ "license": "BSD-2-Clause" }, "node_modules/@zano-project/zano-utils-js": { - "version": "0.0.4", - "resolved": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.1/zano-project-zano-utils-js-0.0.4.tgz", - "integrity": "sha512-abG9HLc40PLODKnwsrnYx2qgHBK0En9j30reJeWJeIAvc0ltXL/YnbA3cr2BK+/ieZ963/WEmIfcIraeeD0h1g==", + "version": "0.0.4-edge.2", + "resolved": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.2/zano-project-zano-utils-js-0.0.4-edge.2.tgz", + "integrity": "sha512-SJLmmDTF5A7mf1DmIXdVRbiyCRFmR2cGLEekF5tt/v4ECqLOcYpNTjC1lU3Ul0UdyuJShTMHEYfHgVoCVqKiiQ==", "license": "MIT", "dependencies": { "axios": "^0.21.1", @@ -15635,9 +15635,9 @@ } }, "node_modules/react-native-zano": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/react-native-zano/-/react-native-zano-0.2.8.tgz", - "integrity": "sha512-t1TiC32GbZ706r8TxkGOnT3p08gPdhPpcYMJSlccbAp14M0M+YnMlb37ddml+0nPsIFrudJJPIvFYSLkEhcimQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/react-native-zano/-/react-native-zano-0.3.0.tgz", + "integrity": "sha512-6LG8Iqnq14pPmIKfHpD4PcCXlvJh7vvR1BafzpX+1MkwHtbugQOuwHD8SCfvWZCfkOhtK/jie2AS2SOVyIosXA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { diff --git a/package.json b/package.json index f701ed7d2..1f1a2f3b9 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "@ton/core": "^0.59.0", "@ton/crypto": "^3.3.0", "@ton/ton": "^15.1.0", - "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.1/zano-project-zano-utils-js-0.0.4.tgz", + "@zano-project/zano-utils-js": "https://github.com/EdgeApp/zano-utils-js/releases/download/v0.0.4-edge.2/zano-project-zano-utils-js-0.0.4-edge.2.tgz", "@zondax/izari-filecoin": "^1.2.6", "algosdk": "^2.1.0", "assert-log": "^0.2.2", @@ -170,7 +170,7 @@ "querystring": "^0.2.1", "react-native-monero": "0.4.0", "react-native-piratechain": "0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "0.13.1", "rimraf": "^3.0.2", "shell-quote": "^1.8.1", @@ -188,7 +188,7 @@ "peerDependencies": { "react-native-monero": "^0.3.0", "react-native-piratechain": "v0.5.0", - "react-native-zano": "^0.2.7", + "react-native-zano": "^0.3.0", "react-native-zcash": "^0.13.1" }, "overrides": { diff --git a/src/zano/ZanoEngine.ts b/src/zano/ZanoEngine.ts index 74a07ae9a..c930347af 100644 --- a/src/zano/ZanoEngine.ts +++ b/src/zano/ZanoEngine.ts @@ -18,7 +18,8 @@ import { import type { CppBridge, RecentTransaction, - TransferParams + TransferParams, + WalletDetails } from 'react-native-zano' import { CurrencyEngine } from '../common/CurrencyEngine' @@ -110,8 +111,32 @@ export class ZanoEngine extends CurrencyEngine< const response = await this.tools.zano.startWallet( keys.mnemonic, keys.passphrase ?? '', - keys.storagePath + keys.storagePath, + { log: message => this.log.warn(message) } ) + + // The public key is derived from the seed phrase, in pure JS for + // wallets without a passphrase. Fail loudly rather than sync a + // wallet whose native address is not the one we show the user. + if (response.wi.address !== this.walletInfo.keys.publicKey) { + // `startWallet` left the wallet open, and the lifecycle manager + // does not run `onStop` for an `onStart` that threw. Left open, + // the next start gets ALREADY_EXISTS and adopts it below, and + // every restart leaks another handle. + try { + await this.tools.zano.closeWallet(response.wallet_id) + } catch (closeError: unknown) { + this.log.warn( + `initializeWallet: could not close the mismatched wallet: ${String( + closeError + )}` + ) + } + throw new Error( + 'initializeWallet: native wallet address does not match the wallet public key' + ) + } + return response.wallet_id } catch (error: unknown) { if (!(error instanceof Error)) throw error @@ -121,19 +146,8 @@ export class ZanoEngine extends CurrencyEngine< `initializeWallet: wallet already exists, finding existing wallet` ) - // Get all opened wallets and find ours by storage path - const openedWalletsResponse = await this.tools.zano.getOpenedWallets() - if ( - !('result' in openedWalletsResponse) || - openedWalletsResponse.result == null - ) { - throw new Error( - 'initializeWallet: Failed to retrieve opened wallets' - ) - } - // Find the wallet that matches our storage path - const existingWallet = openedWalletsResponse.result.find( + const existingWallet = (await this.listOpenedWallets()).find( info => info.name === keys.storagePath ) if (existingWallet?.wallet_id == null) { @@ -142,6 +156,14 @@ export class ZanoEngine extends CurrencyEngine< ) } + // Adopting a wallet has to clear the same bar as opening one, or + // the address check above is bypassed by anything that retries. + if (existingWallet.wi?.address !== this.walletInfo.keys.publicKey) { + throw new Error( + 'initializeWallet: existing native wallet address does not match the wallet public key' + ) + } + this.log( `initializeWallet: found existing wallet with ID ${existingWallet.wallet_id}` ) @@ -159,6 +181,21 @@ export class ZanoEngine extends CurrencyEngine< }) } + /** + * Lists the wallets the native library currently has open. + * + * `get_opened_wallets` reads its snapshot under a shared lock on the wallet + * map and reports each wallet through `get_wallet_info_unlocked`, so unlike + * the per-wallet calls it never waits on a wallet that is mid-refresh. + */ + private async listOpenedWallets(): Promise { + const response = await this.tools.zano.getOpenedWallets() + if (!('result' in response) || response.result == null) { + throw new Error('Could not list the opened Zano wallets') + } + return response.result + } + setOtherData(raw: any): void { this.otherData = asZanoWalletOtherData(raw) } @@ -404,10 +441,27 @@ export class ZanoEngine extends CurrencyEngine< throw new Error('Wallet is not running, cannot get view key') } - const walletInfo = await this.tools.zano.getWalletInfo(nativeId) - return walletInfo.wi_extended.view_private_key + // `getOpenedWallets` reads the view key without taking the per-wallet + // lock, while `getWalletInfo` blocks on it with no timeout. The app + // asks for this key for every wallet shortly after login, so the + // locking call can stall the whole native queue behind a wallet that + // is mid-refresh. + const entry = (await this.listOpenedWallets()).find( + wallet => wallet.wallet_id === nativeId + ) + const viewKey = entry?.wi?.view_sec_key + if (viewKey == null || viewKey === '') { + // Not a bug so much as a race: the wallet closed, or was still + // opening, between our id being handed out and this snapshot. + throw new Error( + `Zano wallet ${nativeId} was not in the opened-wallet list` + ) + } + return viewKey } catch (error: unknown) { - throw new Error('Failed to get wallet info: ' + JSON.stringify(error)) + // `JSON.stringify` on an Error yields `{}`, so every failure here + // used to report the same empty cause. + throw new Error('Failed to get the wallet view key: ' + String(error)) } } diff --git a/src/zano/ZanoTools.ts b/src/zano/ZanoTools.ts index 0430693c4..8e776daba 100644 --- a/src/zano/ZanoTools.ts +++ b/src/zano/ZanoTools.ts @@ -25,6 +25,13 @@ import { asMaybeContractLocation, validateToken } from '../common/tokenHelpers' import { encodeUriCommon, parseUriCommon } from '../common/uriHelpers' import { getLegacyDenomination, mergeDeeply } from '../common/utils' import { parseZanoDeeplink } from './parseZanoDeeplink' +import { + deriveAddressFromMnemonic, + mnemonicMatchesKeysSeed, + normalizeMnemonic, + validateMnemonic, + verifyMnemonicChecksum +} from './zanoMnemonic' import { asZanoAssetDetails, asZanoPrivateKeys, @@ -81,8 +88,12 @@ export class ZanoTools implements EdgeCurrencyTools { ): Promise { const { pluginId } = this.currencyInfo + // Store the normalized form. The wallet-file password is derived from + // the mnemonic, so stray whitespace would otherwise change it. + const mnemonic = normalizeMnemonic(input) + const out = { - [`${pluginId}Mnemonic`]: input + [`${pluginId}Mnemonic`]: mnemonic } const { passphrase } = opts @@ -99,9 +110,21 @@ export class ZanoTools implements EdgeCurrencyTools { } out[`${pluginId}StoragePath`] = storagePath + if (seedPassword === '') { + // Validate offline. `parseUri` calls this for every scanned payload to + // decide whether it is a private key, so it must not touch the native + // library or the network. + validateMnemonic(mnemonic) + return out + } + + // Only the native library can decrypt a passphrase-protected seed, and it + // has to be initialized here rather than left to `ZanoEngine`: importing + // runs before any engine for this wallet exists. `getSeedPhraseInfo` uses + // instance 0, so it opens no wallet and writes no wallet file. await this.zano.init(this.networkInfo.walletRpcAddress, -1) const seedPhraseInfo = await this.zano.getSeedPhraseInfo( - input, + mnemonic, seedPassword ) @@ -122,14 +145,30 @@ export class ZanoTools implements EdgeCurrencyTools { const storagePath = this.createPath() - await this.zano.init(this.networkInfo.walletRpcAddress, -1) - const generatedWallet = await this.zano.generateSeedPhrase( - this.networkInfo.walletRpcAddress, - storagePath, - '' - ) + // Generate from our own entropy rather than through the native library. + // `generateSeedPhrase` writes a wallet file to disk purely as a side + // effect of producing a seed, and it has no way to encrypt that file + // with anything but the seed passphrase. + const keysSeed = base16.stringify(this.io.random(32)) + const mnemonic = seedToMnemonic(keysSeed) + + // A phrase we hand the user is their only backup, and a wallet funded + // against a phrase that does not restore is unrecoverable, so self-check + // both halves of it before returning: the seed words must decode back to + // the entropy above, and the checksum word must match them. + // + // Deliberately no native cross-check: creating a wallet is not a reason to + // start the SDK, and `ZanoEngine` compares the native address against this + // phrase's derived one every time the wallet starts, which catches a + // JS/native disagreement before the wallet can sync. + if (!mnemonicMatchesKeysSeed(mnemonic, keysSeed)) { + throw new Error('Zano seed phrase generation did not round-trip') + } + if (!verifyMnemonicChecksum(mnemonic)) { + throw new Error('Zano seed phrase generation produced a bad checksum') + } - return await this.importPrivateKey(generatedWallet.seed, { storagePath }) + return await this.importPrivateKey(mnemonic, { storagePath }) } async derivePublicKey(walletInfo: EdgeWalletInfo): Promise { @@ -141,12 +180,32 @@ export class ZanoTools implements EdgeCurrencyTools { const zanoPrivateKeys = asZanoPrivateKeys(pluginId)(walletInfo.keys) const { mnemonic, passphrase = '' } = zanoPrivateKeys + if (passphrase === '') { + // Derive offline. `makeMemoryWallet` calls this before an engine + // exists, so it must work without the native library. + return { publicKey: deriveAddressFromMnemonic(mnemonic) } + } + + // Only the native library can decrypt a passphrase-protected seed, and it + // has to be initialized here rather than left to `ZanoEngine`, since + // `makeMemoryWallet` derives before any engine exists. `getSeedPhraseInfo` + // uses instance 0, so it opens no wallet and writes no wallet file. await this.zano.init(this.networkInfo.walletRpcAddress, -1) const seedPhraseInfo = await this.zano.getSeedPhraseInfo( mnemonic, passphrase ) + // The same check `importPrivateKey` makes on this call. Without it a + // wrong passphrase returns `{ publicKey: '' }`, and the wallet is created + // with an empty address rather than failing. + if ( + seedPhraseInfo.error_code !== 'OK' || + seedPhraseInfo.response_data.address === '' + ) { + throw new Error('Unable to derive the Zano address from this mnemonic') + } + return { publicKey: seedPhraseInfo.response_data.address } diff --git a/src/zano/zanoMnemonic.ts b/src/zano/zanoMnemonic.ts new file mode 100644 index 000000000..5d6e3ff9b --- /dev/null +++ b/src/zano/zanoMnemonic.ts @@ -0,0 +1,123 @@ +import { + getAccountBySecretSpendKey, + getMasterAddress, + isSeedPhrasePasswordProtected, + mnemonicToSeed, + verifySeedPhrase +} from '@zano-project/zano-utils-js' + +/** + * Seed-phrase helpers that run without the native Zano library. + * + * These cover unprotected phrases only. `@zano-project/zano-utils-js` + * implements no seed-password support -- decrypting one is part of Zano's + * derivation scheme, and guessing at it would risk deriving a wallet the + * reference implementation cannot restore -- so every helper here refuses a + * passphrase-protected phrase, and callers route those to the native library + * through `ZanoTools`, which is the only implementation that supports them. + */ + +/** + * The canonical form of a seed phrase, used everywhere one is stored, + * compared, or hashed. + * + * Imported phrases arrive with whatever spacing the user or the scanner + * produced, and the wallet-file password is derived from the phrase, so an + * unnormalized phrase would derive a different password for the same wallet. + */ +export function normalizeMnemonic(mnemonic: string): string { + return mnemonic.trim().replace(/\s+/g, ' ') +} + +/** + * Derives the Zano master address from a seed phrase, without calling into + * the native library. + * + * Only valid for phrases that carry no seed passphrase. The pure-JS library + * cannot decrypt a protected seed, and Zano core asserts that the phrase's + * "password used" flag matches the password supplied to it, so a protected + * phrase decoded without its passphrase yields the wrong address rather than + * an error. + */ +export function deriveAddressFromMnemonic(mnemonic: string): string { + const normalized = normalizeMnemonic(mnemonic) + validateMnemonic(normalized) + + const { publicSpendKey, publicViewKey } = getAccountBySecretSpendKey( + mnemonicToSeed(normalized) + ) + return getMasterAddress(publicSpendKey, publicViewKey) +} + +/** Length of a keys seed in hex characters. */ +const KEYS_SEED_HEX_LENGTH = 64 + +/** + * Reports whether a phrase decodes back to the keys seed it was built from. + * + * Used to self-check phrases this plugin generates. A phrase we hand the user + * is their only backup, so it is worth proving that the words carry the + * entropy we meant to encode rather than trusting the encoder. + */ +export function mnemonicMatchesKeysSeed( + mnemonic: string, + keysSeedHex: string +): boolean { + const normalized = normalizeMnemonic(mnemonic) + + // `mnemonicToSeed` does not decrypt, and answers a protected phrase with a + // wrong seed rather than an error, so guard it rather than trusting the + // caller. Only `verifySeedPhrase` refuses these on its own. + if (isSeedPhrasePasswordProtected(normalized)) { + throw new Error('Zano seed phrase requires a passphrase') + } + + // The `full` form is the keys seed followed by the timestamp and checksum + // words, so compare just the seed itself: + const decoded = mnemonicToSeed(normalized, true).slice( + 0, + KEYS_SEED_HEX_LENGTH + ) + return decoded.toLowerCase() === keysSeedHex.toLowerCase() +} + +/** + * Reports whether a phrase's checksum word matches its seed words. + * + * Used to self-check phrases this plugin generates, where a mismatch means a + * bug on our side. It is deliberately not applied to phrases the user + * supplies -- see `validateMnemonic`. + */ +export function verifyMnemonicChecksum(mnemonic: string): boolean { + return verifySeedPhrase(normalizeMnemonic(mnemonic)) +} + +/** + * Validates a seed phrase offline, throwing if it is unusable without the + * native library. + * + * Checks the word count, that every word is in the Zano dictionary, that the + * phrase is not passphrase-protected, and -- for 26-word phrases -- that the + * checksum word matches. + */ +export function validateMnemonic(mnemonic: string): void { + const normalized = normalizeMnemonic(mnemonic) + + // Throws on a bad word count or a word outside the dictionary: + if (isSeedPhrasePasswordProtected(normalized)) { + throw new Error('Zano seed phrase requires a passphrase') + } + + // The native import path this replaced enforced the checksum, by way of + // `account_base::restore_from_seed_phrase` returning false on a mismatch + // and `get_seed_phrase_info` then reporting an empty address. Without this + // a single mistyped word that happens to land on another dictionary word + // imports silently as a different account. + // + // 25-word phrases are unaffected: they carry no checksum word, and + // `verifySeedPhrase` returns true for them before reaching the check -- + // which is also what Zano core does. + if (!verifySeedPhrase(normalized)) { + throw new Error('Zano seed phrase checksum does not match') + } +} diff --git a/test/zano/ZanoToolsKeys.test.ts b/test/zano/ZanoToolsKeys.test.ts new file mode 100644 index 000000000..8d23c05e6 --- /dev/null +++ b/test/zano/ZanoToolsKeys.test.ts @@ -0,0 +1,268 @@ +import { assert } from 'chai' + +import { deriveAddressFromMnemonic } from '../../src/zano/zanoMnemonic' +import { ZanoTools } from '../../src/zano/ZanoTools' + +interface BridgeCall { + method: string + args: unknown[] +} + +interface FakeOpts { + /** Overrides the address the native library reports, to force a mismatch. */ + address?: string + errorCode?: string +} + +/** + * Builds `ZanoTools` without running its constructor, which needs a native + * module. `io.random` is counter-based so the storage path and the seed do + * not come out identical. + */ +const makeTools = (calls: BridgeCall[], opts: FakeOpts = {}): ZanoTools => { + let counter = 0 + + const zano = { + init: async (...args: unknown[]) => { + calls.push({ method: 'init', args }) + return {} + }, + getSeedPhraseInfo: async (mnemonic: string, seedPassword: string) => { + calls.push({ + method: 'getSeedPhraseInfo', + args: [mnemonic, seedPassword] + }) + return { + error_code: opts.errorCode ?? 'OK', + response_data: { + address: opts.address ?? deriveAddressFromMnemonic(mnemonic) + } + } + }, + // Anything that would write a wallet file. Reaching these is the bug. + generateSeedPhrase: async (...args: unknown[]) => { + calls.push({ method: 'generateSeedPhrase', args }) + throw new Error('generateSeedPhrase must not be called') + }, + generate: async (...args: unknown[]) => { + calls.push({ method: 'generate', args }) + throw new Error('generate must not be called') + }, + open: async (...args: unknown[]) => { + calls.push({ method: 'open', args }) + throw new Error('open must not be called') + }, + restore: async (...args: unknown[]) => { + calls.push({ method: 'restore', args }) + throw new Error('restore must not be called') + } + } + + return Object.assign(Object.create(ZanoTools.prototype), { + builtinTokens: {}, + currencyInfo: { pluginId: 'zano', walletType: 'wallet:zano' }, + io: { + random: (size: number) => { + const out = new Uint8Array(size) + out[size - 1] = ++counter + return out + } + }, + networkInfo: { walletRpcAddress: 'http://example.invalid' }, + zano + }) as ZanoTools +} + +/** Methods that create or touch a wallet file on disk. */ +const FILE_METHODS = ['generateSeedPhrase', 'generate', 'open', 'restore'] + +describe('ZanoTools.createPrivateKey', () => { + it('never writes a wallet file', async () => { + // The vulnerability: `generateSeedPhrase` used to write a wallet file + // encrypted with the empty string, purely to obtain a seed phrase. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + await tools.createPrivateKey('wallet:zano') + + const touched = calls.filter(call => FILE_METHODS.includes(call.method)) + assert.deepEqual(touched, []) + }) + + it('returns a usable key set', async () => { + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + const keys = await tools.createPrivateKey('wallet:zano') + + assert.lengthOf(String(keys.zanoMnemonic).split(' '), 26) + assert.match(String(keys.zanoStoragePath), /^[0-9A-F]{64}$/) + assert.isUndefined(keys.zanoPassphrase) + // The storage path must not be derived from the same entropy as the seed: + assert.notEqual(keys.zanoStoragePath, keys.zanoMnemonic) + }) + + it('makes no native calls at all', async () => { + // Creating a wallet is not a reason to start the SDK. The phrase is + // self-checked offline, and `ZanoEngine` compares the native address + // against it on every start. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + await tools.createPrivateKey('wallet:zano') + + assert.deepEqual(calls, []) + }) + + it('rejects the wrong wallet type', async () => { + const tools = makeTools([]) + await assertRejects( + async () => await tools.createPrivateKey('wallet:monero'), + /InvalidWalletType/ + ) + }) +}) + +describe('ZanoTools.importPrivateKey', () => { + let mnemonic: string + + beforeEach(async () => { + const keys = await makeTools([]).createPrivateKey('wallet:zano') + mnemonic = String(keys.zanoMnemonic) + }) + + it('makes no native calls without a passphrase', async () => { + // `parseUri` runs this against every scanned payload to decide whether it + // is a private key, so it must stay offline. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + await tools.importPrivateKey(mnemonic) + + assert.deepEqual(calls, []) + }) + + it('stores the normalized mnemonic', async () => { + const tools = makeTools([]) + + // Scanners and hand-typed input arrive with arbitrary spacing, and the + // wallet-file password is derived from the stored phrase. + const messy = ` ${mnemonic.replace(/ /g, ' ')} ` + const keys = await tools.importPrivateKey(messy) + + assert.equal(keys.zanoMnemonic, mnemonic) + }) + + it('rejects things that are not seed phrases', async () => { + const tools = makeTools([]) + for (const input of [ + '', + 'like like like', + 'ZxDFpn4k7xVYyc9VZ3LphrJbkpc46xfREace5bme1aXiMzKPAHA8jsTWcHSXhv9AdodSaoGXK9Mg7bk3ec4FkQrj357fZPWZX', + 'https://edge.app', + mnemonic.replace('like', 'notazanoword') + ]) { + await assertRejects(async () => await tools.importPrivateKey(input)) + } + }) + + it('uses the native library when a passphrase is supplied', async () => { + const calls: BridgeCall[] = [] + const tools = makeTools(calls, { address: 'ZxAnyAddress' }) + + const keys = await tools.importPrivateKey(mnemonic, { + passphrase: 'hunter2' + }) + + assert.equal(keys.zanoPassphrase, 'hunter2') + const checks = calls.filter(call => call.method === 'getSeedPhraseInfo') + assert.deepEqual(checks[0].args, [mnemonic, 'hunter2']) + }) + + it('rejects a passphrase the native library will not validate', async () => { + const tools = makeTools([], { address: '' }) + await assertRejects( + async () => + await tools.importPrivateKey(mnemonic, { passphrase: 'hunter2' }), + /Unable to validate mnemonic/ + ) + }) + + it('passes the storage path through unchanged', async () => { + const tools = makeTools([]) + const keys = await tools.importPrivateKey(mnemonic, { storagePath: 'AB' }) + assert.equal(keys.zanoStoragePath, 'AB') + }) +}) + +describe('ZanoTools.derivePublicKey', () => { + let mnemonic: string + + beforeEach(async () => { + const keys = await makeTools([]).createPrivateKey('wallet:zano') + mnemonic = String(keys.zanoMnemonic) + }) + + it('derives offline without a passphrase', async () => { + // `makeMemoryWallet` calls this before any engine exists. + const calls: BridgeCall[] = [] + const tools = makeTools(calls) + + const { publicKey } = await tools.derivePublicKey({ + id: 'a', + type: 'wallet:zano', + keys: { zanoMnemonic: mnemonic, zanoStoragePath: 'AB' } + }) + + assert.equal(publicKey, deriveAddressFromMnemonic(mnemonic)) + assert.deepEqual(calls, []) + }) + + it('uses the native library with a passphrase', async () => { + const calls: BridgeCall[] = [] + const tools = makeTools(calls, { address: 'ZxPassphraseAddress' }) + + const { publicKey } = await tools.derivePublicKey({ + id: 'a', + type: 'wallet:zano', + keys: { + zanoMnemonic: mnemonic, + zanoPassphrase: 'hunter2', + zanoStoragePath: 'AB' + } + }) + + assert.equal(publicKey, 'ZxPassphraseAddress') + const checks = calls.filter(call => call.method === 'getSeedPhraseInfo') + assert.deepEqual(checks[0].args, [mnemonic, 'hunter2']) + }) + + it('rejects the wrong wallet type', async () => { + const tools = makeTools([]) + await assertRejects( + async () => + await tools.derivePublicKey({ + id: 'a', + type: 'wallet:monero', + keys: { zanoMnemonic: mnemonic, zanoStoragePath: 'AB' } + }), + /InvalidWalletType/ + ) + }) +}) + +/** Chai's `assert.throws` does not handle promises. */ +async function assertRejects( + fn: () => Promise, + match?: RegExp +): Promise { + try { + await fn() + } catch (error: unknown) { + if (match != null) { + assert.match(String(error), match) + } + return + } + assert.fail('Expected the call to reject') +} diff --git a/test/zano/zanoMnemonic.test.ts b/test/zano/zanoMnemonic.test.ts new file mode 100644 index 000000000..028ecfbba --- /dev/null +++ b/test/zano/zanoMnemonic.test.ts @@ -0,0 +1,170 @@ +import { seedToMnemonic } from '@zano-project/zano-utils-js' +import { assert } from 'chai' +import { randomBytes } from 'crypto' +import { base16 } from 'rfc4648' + +import { + deriveAddressFromMnemonic, + mnemonicMatchesKeysSeed, + normalizeMnemonic, + validateMnemonic +} from '../../src/zano/zanoMnemonic' + +// The all-zero seed. Only the timestamp word (index 24) depends on when the +// phrase was generated, and the address depends only on the 24 seed words, +// so both this literal and the address below are stable. +const mnemonic = + 'like like like like like like like like like like like like like like like like like like like like like like like like shoulder mom' +const address = + 'ZxCErvJMYURjo2LcMJzj5SM7N1YADhuy3hZqiaqvpkBhHWWZBxyTDm1aNpd8ZRfVJVVy3FncPDrGygB4gik9jhJv36cJT4HSg' + +/** Replaces the creation-timestamp word, which carries the passphrase flag. */ +const withTimestampWord = (phrase: string, word: string): string => { + const words = phrase.split(' ') + words[24] = word + return words.join(' ') +} + +describe('normalizeMnemonic', () => { + it('trims and collapses whitespace', () => { + assert.equal(normalizeMnemonic(` ${mnemonic} `), mnemonic) + assert.equal(normalizeMnemonic(mnemonic.replace(/ /g, ' ')), mnemonic) + assert.equal(normalizeMnemonic(`${mnemonic} `), mnemonic) + }) + + it('leaves an already-normal phrase alone', () => { + assert.equal(normalizeMnemonic(mnemonic), mnemonic) + }) +}) + +describe('deriveAddressFromMnemonic', () => { + it('matches its golden vector', () => { + assert.equal(deriveAddressFromMnemonic(mnemonic), address) + }) + + it('normalizes before deriving', () => { + assert.equal(deriveAddressFromMnemonic(` ${mnemonic} `), address) + }) + + it('rejects a passphrase-protected phrase', () => { + // Dictionary index >= 800 sets the "password used" flag. Deriving such a + // phrase without its passphrase would silently produce a wrong address. + assert.throws( + () => deriveAddressFromMnemonic(withTimestampWord(mnemonic, 'among')), + /requires a passphrase/ + ) + }) +}) + +describe('mnemonicMatchesKeysSeed', () => { + const zeroSeed = '00'.repeat(32) + + it('matches the seed its words encode', () => { + assert.isTrue(mnemonicMatchesKeysSeed(mnemonic, zeroSeed)) + }) + + it('ignores hex case and surrounding whitespace', () => { + assert.isTrue(mnemonicMatchesKeysSeed(` ${mnemonic} `, '0'.repeat(64))) + assert.isTrue( + mnemonicMatchesKeysSeed(mnemonic, zeroSeed.toUpperCase()), + 'the plugin generates uppercase hex' + ) + }) + + it('rejects a seed the words do not encode', () => { + assert.isFalse(mnemonicMatchesKeysSeed(mnemonic, `${'00'.repeat(31)}01`)) + }) + + it('refuses a passphrase-protected phrase', () => { + // `mnemonicToSeed` does not decrypt, and answers a protected phrase with + // a wrong seed rather than an error, so this would otherwise report a + // meaningless comparison as a plain false. + assert.throws( + () => + mnemonicMatchesKeysSeed(withTimestampWord(mnemonic, 'among'), zeroSeed), + /requires a passphrase/ + ) + }) + + it('holds for freshly generated phrases', () => { + // The property `createPrivateKey` relies on: what `seedToMnemonic` + // encodes is what the phrase decodes back to. + for (let i = 0; i < 64; ++i) { + const seed = base16.stringify(randomBytes(32)) + assert.isTrue( + mnemonicMatchesKeysSeed(seedToMnemonic(seed), seed), + `failed for seed ${seed}` + ) + } + }) +}) + +describe('validateMnemonic', () => { + it('accepts a well-formed phrase', () => { + assert.doesNotThrow(() => validateMnemonic(mnemonic)) + }) + + it('accepts a 25-word phrase, which carries no checksum', () => { + assert.doesNotThrow(() => + validateMnemonic(mnemonic.split(' ').slice(0, 25).join(' ')) + ) + }) + + it('rejects a 26-word phrase whose checksum does not match', () => { + // The realistic transcription error: one word mistyped into another word + // that is also in the dictionary. The native import path this replaced + // rejected these, so accepting them would import a different account + // under the user's phrase. + const words = mnemonic.split(' ') + words[25] = 'shoulder' + assert.throws(() => validateMnemonic(words.join(' ')), /checksum/) + }) + + it('rejects the wrong word count', () => { + assert.throws(() => validateMnemonic('like like like')) + assert.throws(() => validateMnemonic('')) + }) + + it('rejects a word outside the dictionary', () => { + assert.throws(() => + validateMnemonic(mnemonic.replace('shoulder', 'notazanoword')) + ) + }) + + it('rejects an address rather than a phrase', () => { + assert.throws(() => validateMnemonic(address)) + }) + + it('rejects a passphrase-protected phrase', () => { + assert.throws( + () => validateMnemonic(withTimestampWord(mnemonic, 'among')), + /requires a passphrase/ + ) + }) + + it('treats the last unflagged timestamp word as unprotected', () => { + // The checksum is salted with the timestamp word, so swapping it + // invalidates the checksum and this reaches the later check. Failing + // there rather than on the passphrase check is the point: index 799 is + // still unflagged. + assert.throws( + () => validateMnemonic(withTimestampWord(mnemonic, 'ugly')), + /checksum/ + ) + }) + + it('rejects a phrase whose checksum word does not match', () => { + // The native path this replaced rejected these: + // `account_base::restore_from_seed_phrase` returns false on a checksum + // mismatch, and `get_seed_phrase_info` then reports an empty address, + // which the old `importPrivateKey` threw on. One word mistyped onto + // another dictionary word would otherwise import a different account. + assert.throws( + () => + validateMnemonic( + 'before bring today bleed process melody cruel devil nowhere frozen bit month fur suffocate thigh against volume effort hill worse thick shove world different just love' + ), + /checksum/ + ) + }) +}) diff --git a/test/zano/zanoUtilsChecksum.test.ts b/test/zano/zanoUtilsChecksum.test.ts new file mode 100644 index 000000000..f47be8379 --- /dev/null +++ b/test/zano/zanoUtilsChecksum.test.ts @@ -0,0 +1,47 @@ +import { seedToMnemonic, verifySeedPhrase } from '@zano-project/zano-utils-js' +import { assert } from 'chai' + +// This guards the pinned `@zano-project/zano-utils-js` build rather than our +// own code. Builds before v0.0.4-edge.2 emitted a 25-word phrase with a +// trailing space whenever the checksum landed on CHECKSUM_MAX (813) -- about +// one seed in 814 -- because `(813 << 1) | 0` addressed index 1626 of a +// 1626-word dictionary. Zano core clamps that case to zero. Downgrading the +// pin would silently hand users malformed backup phrases again. + +// The checksum is salted with the creation-timestamp word, which only +// advances weekly, so these vectors reproduce with the clock frozen. +const FIXED_TIME = Date.UTC(2025, 0, 1) +const boundarySeeds = [ + '0000000000000000000000000000000000000000000000000000000000000af1', + '0000000000000000000000000000000000000000000000000000000000000afa', + '0000000000000000000000000000000000000000000000000000000000001561' +] + +describe('zano-utils-js checksum clamp', () => { + const realNow = Date.now + + beforeEach(() => { + Date.now = () => FIXED_TIME + }) + + afterEach(() => { + Date.now = realNow + }) + + for (const seedHex of boundarySeeds) { + it(`generates a complete 26-word phrase for ...${seedHex.slice( + -6 + )}`, () => { + const mnemonic = seedToMnemonic(seedHex) + + assert.equal(mnemonic, mnemonic.trim(), 'phrase has trailing whitespace') + assert.lengthOf(mnemonic.split(/\s+/), 26) + }) + } + + it('verifies the phrases it generates at the boundary', () => { + for (const seedHex of boundarySeeds) { + assert.isTrue(verifySeedPhrase(seedToMnemonic(seedHex)), seedHex) + } + }) +})