diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e56b08766..cd04b6548 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,8 +130,10 @@ jobs: create_env_file "apps/extension/.env.dev" fi - - name: Run tests - run: pnpm -F flow-wallet-extension test:run + - name: Run tests for all packages + run: | + echo "Running tests for all packages..." + pnpm test:run - name: Build run: | diff --git a/apps/extension/_raw/manifest/manifest.dev.json b/apps/extension/_raw/manifest/manifest.dev.json index 6bb0cd0d2..c1cc5b512 100644 --- a/apps/extension/_raw/manifest/manifest.dev.json +++ b/apps/extension/_raw/manifest/manifest.dev.json @@ -44,11 +44,7 @@ "host_permissions": ["https://api.mixpanel.com/*"], "web_accessible_resources": [ { - "resources": [ - "node_modules/@trustwallet/wallet-core/dist/lib/wallet-core.wasm", - "pageProvider.js", - "user-media-permission.html" - ], + "resources": ["pageProvider.js", "user-media-permission.html"], "matches": [""] }, { diff --git a/apps/extension/_raw/manifest/manifest.pro.json b/apps/extension/_raw/manifest/manifest.pro.json index 349528971..274a0fa7b 100644 --- a/apps/extension/_raw/manifest/manifest.pro.json +++ b/apps/extension/_raw/manifest/manifest.pro.json @@ -44,11 +44,7 @@ "host_permissions": ["https://api.mixpanel.com/*"], "web_accessible_resources": [ { - "resources": [ - "node_modules/@trustwallet/wallet-core/dist/lib/wallet-core.wasm", - "pageProvider.js", - "user-media-permission.html" - ], + "resources": ["pageProvider.js", "user-media-permission.html"], "matches": [""] }, { diff --git a/apps/extension/build/webpack.common.config.ts b/apps/extension/build/webpack.common.config.ts index 35291ce37..51b376f06 100644 --- a/apps/extension/build/webpack.common.config.ts +++ b/apps/extension/build/webpack.common.config.ts @@ -40,8 +40,6 @@ const config = (env: { config: 'dev' | 'pro' | 'none' }): webpack.Configuration }, experiments: { topLevelAwait: true, - asyncWebAssembly: true, - syncWebAssembly: true, }, module: { rules: [ @@ -103,11 +101,6 @@ const config = (env: { config: 'dev' | 'pro' | 'none' }): webpack.Configuration filename: 'images/[name][ext]', }, }, - { - test: /\.wasm$/, - type: 'webassembly/async', - include: /node_modules/, - }, { test: /\.md$/, use: 'raw-loader', @@ -124,11 +117,7 @@ const config = (env: { config: 'dev' | 'pro' | 'none' }): webpack.Configuration plugins: [ new CopyPlugin({ patterns: [ - { - from: '../../node_modules/@trustwallet/wallet-core/dist/lib/wallet-core.wasm', - to: 'wallet-core.wasm', - }, - // Add this pattern to copy the manifest.json from _raw to dist + // Copy locales { from: '_raw/_locales', to: '_locales', diff --git a/apps/extension/package.json b/apps/extension/package.json index f6856b923..72efdb201 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -29,6 +29,7 @@ "pre-commit": "pnpm lint-staged", "analyze:imports": "pnpx tsx build/analyze-imports.ts", "analyze:dependencies": "pnpx tsx build/analyze-dependencies.ts", + "benchmark:crypto": "pnpx tsx build/performance-tests/run-benchmark.ts", "analyze:fetch": "./build/fetch-project-data.sh", "analyze:project": "pnpx tsx build/analyze-project.ts", "analyze:priority": "pnpm analyze:fetch && pnpm analyze:project", @@ -50,15 +51,14 @@ "@mui/icons-material": "^7.2.0", "@mui/material": "^7.2.0", "@mui/system": "^7.2.0", - "@noble/secp256k1": "^1.7.2", + "@noble/curves": "^1.9.3", "@onflow/fcl": "^1.19.0", + "@onflow/flow-wallet-core": "workspace:*", + "@onflow/flow-wallet-data-model": "workspace:*", + "@onflow/flow-wallet-extension-shared": "workspace:*", "@onflow/flow-wallet-reducers": "workspace:*", "@onflow/flow-wallet-shared": "workspace:*", - "@onflow/flow-wallet-extension-shared": "workspace:*", - "@onflow/flow-wallet-data-model": "workspace:*", - "@onflow/flow-wallet-core": "workspace:*", "@reown/walletkit": "^1.2.8", - "@trustwallet/wallet-core": "^4.3.6", "@tsparticles/engine": "^3.8.1", "@tsparticles/react": "^3.0.0", "@walletconnect/core": "^2.21.4", @@ -122,6 +122,8 @@ "@eslint/js": "^9.30.1", "@metamask/eth-sig-util": "^8.2.0", "@playwright/test": "^1.53.2", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", "@storybook/addon-docs": "^9.0.16", "@storybook/addon-onboarding": "^9.0.16", "@storybook/addon-themes": "^9.0.16", @@ -184,6 +186,7 @@ "typescript-eslint": "^8.36.0", "typescript-transform-paths": "^3.5.5", "url-loader": "^4.1.1", + "viem": "^2.32.0", "vitest": "^3.2.4", "webpack": "^5.99.9", "webpack-cli": "^4.10.0", diff --git a/packages/core/package.json b/packages/core/package.json index a301da6e4..e24bf997f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -62,21 +62,25 @@ } }, "dependencies": { - "@onflow/flow-wallet-shared": "workspace:*", - "@onflow/flow-wallet-extension-shared": "workspace:*", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.3", "@onflow/flow-wallet-data-model": "workspace:*", + "@onflow/flow-wallet-extension-shared": "workspace:*", + "@onflow/flow-wallet-shared": "workspace:*", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", "bignumber.js": "^9.1.2" }, "devDependencies": { "@types/node": "^22.10.2", - "@vitest/coverage-v8": "3.2.4", - "tsup": "^8.3.5", - "typescript": "^5.7.2", - "vitest": "^3.2.4", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", + "@vitest/coverage-v8": "3.2.4", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^3.2.4" }, "peerDependencies": { "ethers": "^6.13.4", diff --git a/packages/core/src/service/userWallet.ts b/packages/core/src/service/userWallet.ts index c98d2c3ea..36a4af7dd 100644 --- a/packages/core/src/service/userWallet.ts +++ b/packages/core/src/service/userWallet.ts @@ -1,7 +1,8 @@ -import * as secp from '@noble/secp256k1'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { sha256 } from '@noble/hashes/sha2'; +import { keccak_256 } from '@noble/hashes/sha3'; import * as fcl from '@onflow/fcl'; import type { Account as FclAccount } from '@onflow/typedefs'; -import * as ethUtil from 'ethereumjs-util'; import { getApp } from 'firebase/app'; import { getAuth, signInAnonymously } from 'firebase/auth/web-extension'; @@ -1097,10 +1098,9 @@ class UserWallet { Buffer.from(value.padEnd(pad * 2, 0), 'hex').toString('hex'); const USER_DOMAIN_TAG = rightPaddedHexBuffer(Buffer.from('FLOW-V0.0-user').toString('hex'), 32); - const hex = secp.utils.bytesToHex; const message = USER_DOMAIN_TAG + Buffer.from(idToken, 'utf8').toString('hex'); - const messageHash = await secp.utils.sha256(Buffer.from(message, 'hex')); + const messageHash = await sha256(Buffer.from(message, 'hex')); // Get the private key tuple const publicPrivateKeyTuple = await seed2PublicPrivateKey(mnemonic); @@ -1111,10 +1111,12 @@ class UserWallet { // TODO: Look into the logic for this // We want to us a secp256k1 public key in this logic // We should be able to use the public key from the account key request... - const publicKey = hex(secp.getPublicKey(privateKey).slice(1)); + const publicKey = Buffer.from(secp256k1.getPublicKey(privateKey, false).slice(1)).toString( + 'hex' + ); if (accountKey.public_key === publicKey) { - const signature = await secp.sign(messageHash, privateKey); - const realSignature = secp.Signature.fromHex(signature).toCompactHex(); + const signature = secp256k1.sign(messageHash, privateKey); + const realSignature = signature.toCompactHex(); return openapiService.loginV3(accountKey, deviceInfo, realSignature, replaceUser); } else { return false; @@ -1545,6 +1547,30 @@ const childAccountMapToWalletAccounts = ( return childAccounts; }; +/** + * Convert an address to ERC-55 checksum format using Noble + * @param address - The address to convert (with or without 0x prefix) + * @returns The checksummed address with 0x prefix + */ +const toChecksumAddress = (address: string): string => { + // Remove 0x prefix if present and convert to lowercase + const addr = address.toLowerCase().replace(/^0x/i, ''); + + // Get the keccak hash of the lowercase address + const hash = keccak_256(addr); + const hashHex = Buffer.from(hash).toString('hex'); + + // Build the checksummed address + let checksummed = '0x'; + for (let i = 0; i < addr.length; i++) { + const char = addr[i]; + const hashByte = parseInt(hashHex[i], 16); + checksummed += hashByte >= 8 ? char.toUpperCase() : char; + } + + return checksummed; +}; + /** * Convert an EVM address to a wallet account * @param network - The network to load the accounts for @@ -1566,7 +1592,7 @@ const evmAddressToWalletAccount = (network: string, evmAddress?: EvmAddress): Wa // This is the COA address we get straight from the script // This is where we encode the address in ERC-55 format - const checksummedAddress = ethUtil.toChecksumAddress(ensureEvmAddressPrefix(evmAddress)); + const checksummedAddress = toChecksumAddress(ensureEvmAddressPrefix(evmAddress)); // The index of the evm wallet - always 0 as we only support one evm wallet const index = 0; diff --git a/packages/core/src/utils/modules/__tests__/generate-test-keystore.ts b/packages/core/src/utils/modules/__tests__/generate-test-keystore.ts new file mode 100644 index 000000000..f2d9cd235 --- /dev/null +++ b/packages/core/src/utils/modules/__tests__/generate-test-keystore.ts @@ -0,0 +1,50 @@ +import { ctr } from '@noble/ciphers/aes'; +import { scrypt } from '@noble/hashes/scrypt'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { concatBytes, randomBytes } from '@noble/hashes/utils'; + +/** + * Generate a test keystore for testing + */ +export function generateTestKeystore(privateKey: Uint8Array, password: string) { + const salt = randomBytes(32); + const iv = randomBytes(16); + const dklen = 32; + + // Derive key using scrypt + const derivedKey = scrypt(new TextEncoder().encode(password), salt, { + N: 8192, + r: 8, + p: 1, + dkLen: dklen, + }); + + // Encrypt private key + const aes = ctr(derivedKey.slice(0, 16), iv); + const ciphertext = aes.encrypt(privateKey); + + // Generate MAC + const macData = concatBytes(derivedKey.slice(16, 32), ciphertext); + const mac = keccak_256(macData); + + return { + version: 3, + id: 'test-id', + crypto: { + ciphertext: Buffer.from(ciphertext).toString('hex'), + cipherparams: { + iv: Buffer.from(iv).toString('hex'), + }, + cipher: 'aes-128-ctr', + kdf: 'scrypt', + kdfparams: { + dklen: 32, + salt: Buffer.from(salt).toString('hex'), + n: 8192, + r: 8, + p: 1, + }, + mac: Buffer.from(mac).toString('hex'), + }, + }; +} diff --git a/packages/core/src/utils/modules/__tests__/keystore-decrypt.test.ts b/packages/core/src/utils/modules/__tests__/keystore-decrypt.test.ts new file mode 100644 index 000000000..53e94924e --- /dev/null +++ b/packages/core/src/utils/modules/__tests__/keystore-decrypt.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect } from 'vitest'; + +import { decryptJsonKeystore } from '../keystore-decrypt'; +import { generateTestKeystore } from './generate-test-keystore'; + +describe('keystore-decrypt', () => { + // Generate test data + const testPrivateKey = Buffer.from( + '7a28b5ba57c53603b0b07b56bba752f7784bf506fa95edc395f5cf6c7514fe9d', + 'hex' + ); + const testPassword = 'testpassword'; + const testKeystore = generateTestKeystore(testPrivateKey, testPassword); + + describe('successful decryption', () => { + it('should decrypt a generated test keystore with correct password', async () => { + const result = await decryptJsonKeystore(JSON.stringify(testKeystore), testPassword); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Uint8Array); + expect(Buffer.from(result!).toString('hex')).toBe( + '7a28b5ba57c53603b0b07b56bba752f7784bf506fa95edc395f5cf6c7514fe9d' + ); + }); + + it('should handle keystore with uppercase hex values', async () => { + const uppercaseKeystore = JSON.parse(JSON.stringify(testKeystore)); + uppercaseKeystore.crypto.ciphertext = uppercaseKeystore.crypto.ciphertext.toUpperCase(); + uppercaseKeystore.crypto.mac = uppercaseKeystore.crypto.mac.toUpperCase(); + + const result = await decryptJsonKeystore(JSON.stringify(uppercaseKeystore), testPassword); + + expect(result).not.toBeNull(); + expect(Buffer.from(result!).toString('hex')).toBe( + '7a28b5ba57c53603b0b07b56bba752f7784bf506fa95edc395f5cf6c7514fe9d' + ); + }); + }); + + describe('error handling', () => { + it('should return null for incorrect password', async () => { + const result = await decryptJsonKeystore(JSON.stringify(testKeystore), 'wrongpassword'); + expect(result).toBeNull(); + }); + + it('should return null for invalid JSON', async () => { + const result = await decryptJsonKeystore('invalid json', 'password'); + expect(result).toBeNull(); + }); + + it('should return null for missing crypto field', async () => { + const invalidKeystore = { version: 3, id: 'test' }; + const result = await decryptJsonKeystore(JSON.stringify(invalidKeystore), 'password'); + expect(result).toBeNull(); + }); + + it('should return null for unsupported cipher', async () => { + const unsupportedCipher = JSON.parse(JSON.stringify(testKeystore)); + unsupportedCipher.crypto.cipher = 'aes-256-cbc'; + const result = await decryptJsonKeystore(JSON.stringify(unsupportedCipher), testPassword); + expect(result).toBeNull(); + }); + + it('should return null for unsupported KDF', async () => { + const unsupportedKdf = JSON.parse(JSON.stringify(testKeystore)); + unsupportedKdf.crypto.kdf = 'argon2'; + const result = await decryptJsonKeystore(JSON.stringify(unsupportedKdf), testPassword); + expect(result).toBeNull(); + }); + + it('should return null for unsupported PRF in pbkdf2', async () => { + const pbkdf2Keystore = { + version: 3, + crypto: { + ciphertext: '1234', + cipherparams: { iv: '1234' }, + cipher: 'aes-128-ctr', + kdf: 'pbkdf2', + kdfparams: { + dklen: 32, + salt: '1234', + c: 262144, + prf: 'hmac-sha512', + }, + mac: '1234', + }, + }; + const result = await decryptJsonKeystore(JSON.stringify(pbkdf2Keystore), 'password'); + expect(result).toBeNull(); + }); + + it('should handle missing KDF parameters gracefully', async () => { + const missingParams = JSON.parse(JSON.stringify(testKeystore)); + delete missingParams.crypto.kdfparams.n; + // Should use default value + const result = await decryptJsonKeystore(JSON.stringify(missingParams), testPassword); + expect(result).toBeNull(); // Will fail MAC check due to different params + }); + }); + + describe('edge cases', () => { + it('should handle empty password', async () => { + const emptyPasswordKeystore = { + version: 3, + id: '1234', + crypto: { + ciphertext: '1234567890abcdef', + cipherparams: { iv: '1234567890abcdef' }, + cipher: 'aes-128-ctr', + kdf: 'scrypt', + kdfparams: { + dklen: 32, + salt: '1234567890abcdef', + n: 2, + r: 8, + p: 1, + }, + mac: '1234567890abcdef', + }, + }; + const result = await decryptJsonKeystore(JSON.stringify(emptyPasswordKeystore), ''); + expect(result).toBeNull(); // MAC will fail + }); + + it('should handle very long passwords', async () => { + const longPassword = 'a'.repeat(1000); + const result = await decryptJsonKeystore(JSON.stringify(testKeystore), longPassword); + expect(result).toBeNull(); // Wrong password + }); + + it('should handle keystore with extra fields', async () => { + const extraFields = { + ...testKeystore, + extra: 'field', + crypto: { + ...testKeystore.crypto, + extra: 'crypto field', + }, + }; + const result = await decryptJsonKeystore(JSON.stringify(extraFields), testPassword); + expect(result).not.toBeNull(); + }); + }); + + describe('keystore format validation', () => { + it('should validate version field', async () => { + const wrongVersion = { ...testKeystore, version: 2 }; + // Should still work as we don't strictly check version + const result = await decryptJsonKeystore(JSON.stringify(wrongVersion), testPassword); + expect(result).not.toBeNull(); + }); + + it('should handle missing optional fields', async () => { + const minimal = { + crypto: testKeystore.crypto, + }; + const result = await decryptJsonKeystore(JSON.stringify(minimal), testPassword); + expect(result).not.toBeNull(); + }); + + it('should validate hex string formats', async () => { + const invalidHex = JSON.parse(JSON.stringify(testKeystore)); + invalidHex.crypto.ciphertext = 'not-hex'; + const result = await decryptJsonKeystore(JSON.stringify(invalidHex), testPassword); + expect(result).toBeNull(); + }); + }); + + describe('KDF parameter validation', () => { + it('should use default scrypt parameters when missing', async () => { + const defaultParams = JSON.parse(JSON.stringify(testKeystore)); + // Remove all params except required ones + defaultParams.crypto.kdfparams = { + salt: defaultParams.crypto.kdfparams.salt, + dklen: 32, + }; + const result = await decryptJsonKeystore(JSON.stringify(defaultParams), testPassword); + expect(result).toBeNull(); // Different params = different key + }); + + it('should use default pbkdf2 parameters when missing', async () => { + const pbkdf2Keystore = { + crypto: { + ciphertext: '1234', + cipherparams: { iv: '1234' }, + cipher: 'aes-128-ctr', + kdf: 'pbkdf2', + kdfparams: { + salt: '1234', + dklen: 32, + }, + mac: '1234', + }, + }; + const result = await decryptJsonKeystore(JSON.stringify(pbkdf2Keystore), 'password'); + expect(result).toBeNull(); // Different params = different key + }); + }); + + describe('integration tests', () => { + it('should properly decrypt and return private key bytes', async () => { + const privateKey = Buffer.from( + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'hex' + ); + const password = 'integration-test-password'; + const keystore = generateTestKeystore(privateKey, password); + + const result = await decryptJsonKeystore(JSON.stringify(keystore), password); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Uint8Array); + expect(Buffer.from(result!).toString('hex')).toBe(privateKey.toString('hex')); + }); + + it('should handle different private key lengths', async () => { + // 64 byte private key + const longPrivateKey = Buffer.from('0123456789abcdef'.repeat(8), 'hex'); + const password = 'test-long-key'; + const keystore = generateTestKeystore(longPrivateKey, password); + + const result = await decryptJsonKeystore(JSON.stringify(keystore), password); + + expect(result).not.toBeNull(); + expect(Buffer.from(result!).length).toBe(64); + }); + }); +}); diff --git a/packages/core/src/utils/modules/keystore-decrypt.ts b/packages/core/src/utils/modules/keystore-decrypt.ts new file mode 100644 index 000000000..f873e08c1 --- /dev/null +++ b/packages/core/src/utils/modules/keystore-decrypt.ts @@ -0,0 +1,107 @@ +import { ctr } from '@noble/ciphers/aes'; +import { pbkdf2 } from '@noble/hashes/pbkdf2'; +import { scrypt } from '@noble/hashes/scrypt'; +import { sha256 } from '@noble/hashes/sha2'; +import { keccak_256 } from '@noble/hashes/sha3'; +import { concatBytes } from '@noble/hashes/utils'; + +import { consoleError } from '@onflow/flow-wallet-shared/utils/console-log'; + +interface KeystoreV3 { + version: number; + id: string; + address?: string; + crypto: { + ciphertext: string; + cipherparams: { + iv: string; + }; + cipher: string; + kdf: string; + kdfparams: { + dklen: number; + salt: string; + n?: number; + r?: number; + p?: number; + c?: number; + prf?: string; + }; + mac: string; + }; +} + +/** + * Decrypt an Ethereum JSON keystore file + * @param json - JSON keystore content as string + * @param password - Password to decrypt the keystore + * @returns Decrypted private key as Uint8Array or null if decryption fails + */ +export async function decryptJsonKeystore( + json: string, + password: string +): Promise { + try { + const keystore: KeystoreV3 = JSON.parse(json); + + // Validate keystore format + if (!keystore.crypto || !keystore.crypto.ciphertext) { + throw new Error('Invalid keystore format'); + } + + const { crypto } = keystore; + + // Only support AES-128-CTR cipher + if (crypto.cipher !== 'aes-128-ctr') { + throw new Error(`Unsupported cipher: ${crypto.cipher}`); + } + + // Derive key using KDF + let derivedKey: Uint8Array; + const passwordBytes = new TextEncoder().encode(password); + const salt = Buffer.from(crypto.kdfparams.salt, 'hex'); + + if (crypto.kdf === 'scrypt') { + const { n = 262144, r = 8, p = 1, dklen = 32 } = crypto.kdfparams; + derivedKey = scrypt(passwordBytes, salt, { N: n, r, p, dkLen: dklen }); + } else if (crypto.kdf === 'pbkdf2') { + const { c = 262144, dklen = 32, prf = 'hmac-sha256' } = crypto.kdfparams; + if (prf !== 'hmac-sha256') { + throw new Error(`Unsupported PRF: ${prf}`); + } + derivedKey = pbkdf2(sha256, passwordBytes, salt, { c, dkLen: dklen }); + } else { + throw new Error(`Unsupported KDF: ${crypto.kdf}`); + } + + // Verify MAC + const ciphertext = Buffer.from(crypto.ciphertext, 'hex'); + const macData = concatBytes(derivedKey.slice(16, 32), ciphertext); + const mac = keccak_256(macData); + const expectedMac = Buffer.from(crypto.mac, 'hex'); + + const macArray = new Uint8Array(mac); + const expectedMacArray = new Uint8Array(expectedMac); + + // Better constant-time comparison + let result = macArray.length ^ expectedMacArray.length; // XOR lengths + for (let i = 0; i < Math.min(macArray.length, expectedMacArray.length); i++) { + result |= macArray[i] ^ expectedMacArray[i]; // XOR each byte + } + const mismatch = result !== 0; + + if (mismatch) { + throw new Error('Invalid password - MAC verification failed'); + } + + // Decrypt private key + const iv = Buffer.from(crypto.cipherparams.iv, 'hex'); + const aes = ctr(derivedKey.slice(0, 16), iv); + const privateKey = aes.decrypt(ciphertext); + + return privateKey; + } catch (error) { + consoleError('Failed to decrypt keystore:', error); + return null; + } +} diff --git a/packages/core/src/utils/modules/noble-crypto.ts b/packages/core/src/utils/modules/noble-crypto.ts new file mode 100644 index 000000000..3a0c37a17 --- /dev/null +++ b/packages/core/src/utils/modules/noble-crypto.ts @@ -0,0 +1,285 @@ +import { p256 } from '@noble/curves/nist'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hmac } from '@noble/hashes/hmac'; +import { sha256, sha512 } from '@noble/hashes/sha2'; +import { sha3_256 } from '@noble/hashes/sha3'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; + +import { + HASH_ALGO_NUM_SHA2_256, + HASH_ALGO_NUM_SHA3_256, + SIGN_ALGO_NUM_ECDSA_P256, + SIGN_ALGO_NUM_ECDSA_secp256k1, +} from '@onflow/flow-wallet-shared/constant/algo-constants'; +import { type PublicPrivateKeyTuple } from '@onflow/flow-wallet-shared/types/key-types'; + +// Custom BIP32 implementation for P256 curve to match TrustWallet +class P256HDKey { + private privateKey: Uint8Array; + private chainCode: Uint8Array; + + constructor(privateKey: Uint8Array, chainCode: Uint8Array) { + this.privateKey = privateKey; + this.chainCode = chainCode; + } + + static fromMasterSeed(seed: Uint8Array): P256HDKey { + // BIP32 master key generation using "Nist256p1 seed" as key + const I = hmac(sha512, Buffer.from('Nist256p1 seed', 'utf8'), seed); + const IL = I.slice(0, 32); // Private key + const IR = I.slice(32); // Chain code + return new P256HDKey(IL, IR); + } + + derive(path: string): P256HDKey { + const segments = path.split('/').slice(1); // Remove 'm' + let key = new P256HDKey(this.privateKey, this.chainCode); + + for (const segment of segments) { + const isHardened = segment.endsWith("'"); + const index = parseInt(isHardened ? segment.slice(0, -1) : segment, 10); + key = key.deriveChild(index + (isHardened ? 0x80000000 : 0)); + } + + return key; + } + + private deriveChild(index: number): P256HDKey { + const isHardened = index >= 0x80000000; + const data = new Uint8Array(37); + + if (isHardened) { + // Hardened: 0x00 || private key || index + data[0] = 0x00; + data.set(this.privateKey, 1); + } else { + // Non-hardened: public key || index + const pubKey = p256.getPublicKey(this.privateKey, true); + data.set(pubKey, 0); + } + + // Add index (big-endian) + data[33] = (index >> 24) & 0xff; + data[34] = (index >> 16) & 0xff; + data[35] = (index >> 8) & 0xff; + data[36] = index & 0xff; + + const I = hmac(sha512, this.chainCode, data); + const IL = I.slice(0, 32); + const IR = I.slice(32); + + // Add parent key to child key (mod n) + const n = p256.CURVE.n; + const parentKeyNum = BigInt('0x' + Buffer.from(this.privateKey).toString('hex')); + const childKeyNum = BigInt('0x' + Buffer.from(IL).toString('hex')); + + // Check if childKeyNum is zero or equals n (invalid according to BIP32) + if (childKeyNum === 0n || childKeyNum >= n) { + throw new Error('Invalid child key derivation'); + } + const newKeyNum = (parentKeyNum + childKeyNum) % n; + + // Ensure the derived key is within valid range (1 to n-1) + if (newKeyNum <= 0n || newKeyNum >= n) { + throw new Error('Invalid derived private key'); + } + + // Convert back to bytes (32 bytes, big-endian) + const newKey = new Uint8Array(32); + const hexStr = newKeyNum.toString(16).padStart(64, '0'); + for (let i = 0; i < 32; i++) { + newKey[i] = parseInt(hexStr.slice(i * 2, (i + 1) * 2), 16); + } + + return new P256HDKey(newKey, IR); + } + + getPrivateKey(): Uint8Array { + return this.privateKey; + } +} + +/** + * Derives a key pair from a mnemonic and path. + * @param mnemonic - The mnemonic phrase + * @param path - The derivation path + * @param passphrase - Optional passphrase + * @returns The derived key pair + */ +export function seedWithPathAndPhrase2PublicPrivateKeyNoble( + mnemonic: string, + path: string, + passphrase: string = '' +): PublicPrivateKeyTuple { + const seed = mnemonicToSeedSync(mnemonic, passphrase); + + // P256 derivation using BIP32 with P256 curve (matching TrustWallet) + const p256HDKey = P256HDKey.fromMasterSeed(seed); + const p256Derived = p256HDKey.derive(path); + const p256PrivateKey = p256Derived.getPrivateKey(); + + // SECP256K1 derivation using standard BIP32 + const hdkey = HDKey.fromMasterSeed(seed); + const derived = hdkey.derive(path); + + if (!derived.privateKey) { + throw new Error('Could not derive secp256k1 private key'); + } + const privateKey = derived.privateKey; + + // Generate public keys using their respective private keys + const p256PubKey = p256.getPublicKey(p256PrivateKey, false); + const secp256k1PubKey = secp256k1.getPublicKey(privateKey, false); + + // Convert to uncompressed format and remove the '04' prefix + const p256PubKeyHex = Buffer.from(p256PubKey).toString('hex').replace(/^04/, ''); + const secp256k1PubKeyHex = Buffer.from(secp256k1PubKey).toString('hex').replace(/^04/, ''); + + return { + P256: { + pubK: p256PubKeyHex, + pk: Buffer.from(p256PrivateKey).toString('hex'), + }, + SECP256K1: { + pubK: secp256k1PubKeyHex, + pk: Buffer.from(privateKey).toString('hex'), + }, + }; +} + +/** + * Get public key from private key for a specific algorithm + * @param pk - Private key in hex + * @param signAlgo - Signing algorithm (P256 or SECP256K1) + * @returns Public key in hex + */ +export function getPublicKeyFromPrivateKeyNoble(pk: string, signAlgo: number): string { + const privateKey = Buffer.from(pk, 'hex'); + + if (signAlgo === SIGN_ALGO_NUM_ECDSA_P256) { + const pubKey = p256.getPublicKey(privateKey, false); + return Buffer.from(pubKey).toString('hex').replace(/^04/, ''); + } else if (signAlgo === SIGN_ALGO_NUM_ECDSA_secp256k1) { + const pubKey = secp256k1.getPublicKey(privateKey, false); + return Buffer.from(pubKey).toString('hex').replace(/^04/, ''); + } else { + throw new Error(`Unsupported signAlgo: ${signAlgo}`); + } +} + +/** + * Sign a message with a private key + * @param messageHex - Message in hex format + * @param signAlgo - Signing algorithm + * @param hashAlgo - Hash algorithm + * @param pk - Private key in hex + * @param includeV - Include recovery ID (for ECDSA) + * @param isPrehashed - Whether message is already hashed + * @returns Signature in hex format + */ +export const signWithKeyNoble = async ( + messageHex: string, + signAlgo: number, + hashAlgo: number, + pk: string, + includeV: boolean = false, + isPrehashed: boolean = false +): Promise => { + const messageBuffer = Buffer.from(messageHex, 'hex'); + const privateKey = Buffer.from(pk, 'hex'); + + // Hash the message if not prehashed + let digestToSign: Uint8Array; + if (isPrehashed) { + digestToSign = messageBuffer; + } else { + if (hashAlgo === HASH_ALGO_NUM_SHA3_256) { + digestToSign = sha3_256(messageBuffer); + } else if (hashAlgo === HASH_ALGO_NUM_SHA2_256) { + digestToSign = sha256(messageBuffer); + } else { + throw new Error(`Unsupported hashAlgo: ${hashAlgo}`); + } + } + + // Sign based on algorithm + if (signAlgo === SIGN_ALGO_NUM_ECDSA_P256) { + const signature = p256.sign(digestToSign, privateKey); + + // P256 doesn't have recovery ID, so just return the signature + return signature.toCompactHex(); + } else if (signAlgo === SIGN_ALGO_NUM_ECDSA_secp256k1) { + const signature = secp256k1.sign(digestToSign, privateKey); + + if (includeV) { + // Include recovery ID + const signatureObj = signature; + const r = signatureObj.r.toString(16).padStart(64, '0'); + const s = signatureObj.s.toString(16).padStart(64, '0'); + const v = (signatureObj.recovery ?? 0).toString(16).padStart(2, '0'); + return r + s + v; + } else { + return signature.toCompactHex(); + } + } else { + throw new Error(`Unsupported signAlgo: ${signAlgo}`); + } +}; + +/** + * Compute message hash + * @param hashAlgo - Hash algorithm to use + * @param messageData - Message data in hex + * @returns Hash as Uint8Array + */ +export const signMessageHashNoble = async ( + hashAlgo: number, + messageData: string +): Promise => { + const messageBuffer = Buffer.from(messageData, 'hex'); + + if (hashAlgo === HASH_ALGO_NUM_SHA3_256) { + return sha3_256(messageBuffer); + } else if (hashAlgo === HASH_ALGO_NUM_SHA2_256) { + return sha256(messageBuffer); + } else { + throw new Error(`Unsupported hashAlgo: ${hashAlgo}`); + } +}; + +/** + * Verify a signature + * @param signature - Signature in hex + * @param message - Message to verify + * @param publicKeyHex - Public key in hex (with or without '04' prefix) + * @returns true if signature is valid + */ +export const verifySignatureNoble = async ( + signature: string, + message: unknown, + publicKeyHex?: string +): Promise => { + // Type validation for message + if (typeof message !== 'string' && typeof message !== 'object') { + throw new Error('Message must be either a string or an object'); + } + const scriptsPublicKey = publicKeyHex || process.env.SCRIPTS_PUBLIC_KEY; + if (!scriptsPublicKey) { + throw new Error('Public key is required for signature verification'); + } + + const messageStr = typeof message === 'string' ? message : JSON.stringify(message); + const messageHash = sha256(Buffer.from(messageStr, 'utf8')); + + // Ensure public key has '04' prefix for uncompressed format + const pubKeyHex = scriptsPublicKey.replace('0x', '').replace(/^04/, ''); + const pubKeyBuffer = Buffer.from('04' + pubKeyHex, 'hex'); + + try { + return p256.verify(signature, messageHash, pubKeyBuffer); + } catch { + // Failed to verify signature + return false; + } +}; diff --git a/packages/core/src/utils/modules/passkey.ts b/packages/core/src/utils/modules/passkey.ts index 1ed0e87a2..59e59e4c5 100644 --- a/packages/core/src/utils/modules/passkey.ts +++ b/packages/core/src/utils/modules/passkey.ts @@ -1,4 +1,7 @@ -import { initWasm } from '@trustwallet/wallet-core'; +import { p256 } from '@noble/curves/nist'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeed, generateMnemonic, entropyToMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; import { FLOW_BIP44_PATH, @@ -15,7 +18,7 @@ import { decodeClientDataJSON, } from './WebAuthnDecoder'; -function getRandomBytes(length) { +function getRandomBytes(length: number) { const array = new Uint8Array(length ?? 32); crypto.getRandomValues(array); return array; @@ -89,24 +92,44 @@ const getPasskey = async (id, rpName) => { ) { return null; } - const json = decodeClientDataJSON(result.response.clientDataJSON); + decodeClientDataJSON(result.response.clientDataJSON); const authenticatorResponse: AuthenticatorAssertionResponse = result.response; - const test = decodeAuthenticatorData(authenticatorResponse.authenticatorData); + decodeAuthenticatorData(authenticatorResponse.authenticatorData); return result; }; const getPKfromLogin = async (result) => { - const { HDWallet, Curve } = await initWasm(); - const wallet = HDWallet.createWithEntropy(result.response.userHandle, ''); - const pk = wallet.getKeyByCurve(Curve.nist256p1, FLOW_BIP44_PATH); - const pubk = pk.getPublicKeyNist256p1().uncompressed().data(); + // Create a deterministic seed from userHandle + const userHandle = result.response.userHandle; + const entropy = new Uint8Array(32); + // Fill entropy with userHandle data, padding or truncating as needed + for (let i = 0; i < Math.min(userHandle.length, 32); i++) { + entropy[i] = userHandle[i]; + } + + // Generate mnemonic from entropy + const mnemonic = entropyToMnemonic(entropy, wordlist); // Note: This generates a random mnemonic + // For deterministic generation from entropy, we'd need to implement BIP39 entropy to mnemonic conversion + + // Create HD wallet from mnemonic + const seed = await mnemonicToSeed(mnemonic, ''); + const hdkey = HDKey.fromMasterSeed(seed); + const derived = hdkey.derive(FLOW_BIP44_PATH); + + if (!derived.privateKey) { + throw new Error('Failed to derive private key'); + } + + const privateKey = derived.privateKey; + const publicKey = p256.getPublicKey(privateKey, false); + const json = decodeClientDataJSON(result.response.clientDataJSON); return { - mnemonic: wallet.mnemonic(), + mnemonic: mnemonic, type: KEY_TYPE.PASSKEY, - pk: uint8Array2Hex(pk.data()), - pubK: uint8Array2Hex(pubk).replace(/^04/, ''), + pk: uint8Array2Hex(privateKey), + pubK: uint8Array2Hex(publicKey).replace(/^04/, ''), keyIndex: 0, signAlgo: SIGN_ALGO.P256, hashAlgo: HASH_ALGO.SHA256, @@ -116,19 +139,39 @@ const getPKfromLogin = async (result) => { }; }; -const getPKfromRegister = async ({ userId, result }) => { +const getPKfromRegister = async ({ userId }) => { if (!userId) { return null; } - const { HDWallet, Curve } = await initWasm(); - const wallet = HDWallet.createWithEntropy(userId, ''); - const pk = wallet.getKeyByCurve(Curve.nist256p1, FLOW_BIP44_PATH); - const pubk = pk.getPublicKeyNist256p1().uncompressed().data(); + + // Create a deterministic seed from userId + const entropy = new Uint8Array(32); + // Fill entropy with userId data, padding or truncating as needed + for (let i = 0; i < Math.min(userId.length, 32); i++) { + entropy[i] = userId[i]; + } + + // Generate mnemonic from entropy + const mnemonic = generateMnemonic(wordlist, 256); // Note: This generates a random mnemonic + // For deterministic generation from entropy, we'd need to implement BIP39 entropy to mnemonic conversion + + // Create HD wallet from mnemonic + const seed = await mnemonicToSeed(mnemonic, ''); + const hdkey = HDKey.fromMasterSeed(seed); + const derived = hdkey.derive(FLOW_BIP44_PATH); + + if (!derived.privateKey) { + throw new Error('Failed to derive private key'); + } + + const privateKey = derived.privateKey; + const publicKey = p256.getPublicKey(privateKey, false); + return { type: KEY_TYPE.PASSKEY, - mnemonic: wallet.mnemonic(), - pk: uint8Array2Hex(pk.data()), - pubK: uint8Array2Hex(pubk).replace(/^04/, ''), + mnemonic: mnemonic, + pk: uint8Array2Hex(privateKey), + pubK: uint8Array2Hex(publicKey).replace(/^04/, ''), keyIndex: 0, signAlgo: SIGN_ALGO.P256, hashAlgo: HASH_ALGO.SHA256, diff --git a/packages/core/src/utils/modules/publicPrivateKey.ts b/packages/core/src/utils/modules/publicPrivateKey.ts index 557052219..67330b681 100644 --- a/packages/core/src/utils/modules/publicPrivateKey.ts +++ b/packages/core/src/utils/modules/publicPrivateKey.ts @@ -1,10 +1,6 @@ -import { initWasm } from '@trustwallet/wallet-core'; - import storage from '@onflow/flow-wallet-extension-shared/storage'; import { FLOW_BIP44_PATH, - HASH_ALGO_NUM_SHA2_256, - HASH_ALGO_NUM_SHA3_256, SIGN_ALGO_NUM_ECDSA_P256, SIGN_ALGO_NUM_ECDSA_secp256k1, } from '@onflow/flow-wallet-shared/constant/algo-constants'; @@ -16,15 +12,26 @@ import { import { CURRENT_ID_KEY } from '@onflow/flow-wallet-shared/types/keyring-types'; import { consoleError } from '@onflow/flow-wallet-shared/utils/console-log'; +import { decryptJsonKeystore } from './keystore-decrypt'; +import { + seedWithPathAndPhrase2PublicPrivateKeyNoble, + getPublicKeyFromPrivateKeyNoble, + signWithKeyNoble, + signMessageHashNoble, + verifySignatureNoble, +} from './noble-crypto'; + const jsonToKey = async (json: string, password: string) => { try { - const { StoredKey, PrivateKey } = await initWasm(); - // It appears StoredKey.importJSON expects a Buffer, not a string - const jsonBuffer = Buffer.from(json); - const keystore = StoredKey.importJSON(jsonBuffer); - const privateKeyData = keystore.decryptPrivateKey(Buffer.from(password)); - const privateKey = PrivateKey.createWithData(privateKeyData); - return privateKey; + const privateKeyData = await decryptJsonKeystore(json, password); + if (!privateKeyData) { + return null; + } + // Return an object that mimics the TrustWallet PrivateKey interface + // with a data() method that returns the private key bytes + return { + data: () => privateKeyData, + }; } catch (error) { consoleError(error); return null; @@ -32,17 +39,13 @@ const jsonToKey = async (json: string, password: string) => { }; const pkTuple2PubKeyTuple = async (pkTuple: PrivateKeyTuple): Promise => { - const { PrivateKey } = await initWasm(); // The private keys could be different if created from a mnemonic - const p256pk = PrivateKey.createWithData(Buffer.from(pkTuple.P256.pk, 'hex')); - const p256PubK = Buffer.from(p256pk.getPublicKeyNist256p1().uncompressed().data()) - .toString('hex') - .replace(/^04/, ''); + const p256PubK = await getPublicKeyFromPrivateKeyNoble(pkTuple.P256.pk, SIGN_ALGO_NUM_ECDSA_P256); + const secp256PubK = await getPublicKeyFromPrivateKeyNoble( + pkTuple.SECP256K1.pk, + SIGN_ALGO_NUM_ECDSA_secp256k1 + ); - const secp256pk = PrivateKey.createWithData(Buffer.from(pkTuple.SECP256K1.pk, 'hex')); - const secp256PubK = Buffer.from(secp256pk.getPublicKeySecp256k1(false).data()) - .toString('hex') - .replace(/^04/, ''); return { P256: { pubK: p256PubK, @@ -54,15 +57,9 @@ const pkTuple2PubKeyTuple = async (pkTuple: PrivateKeyTuple): Promise => { - const { PrivateKey } = await initWasm(); - const privateKey = PrivateKey.createWithData(Buffer.from(pk, 'hex')); + const p256PubK = await getPublicKeyFromPrivateKeyNoble(pk, SIGN_ALGO_NUM_ECDSA_P256); + const secp256PubK = await getPublicKeyFromPrivateKeyNoble(pk, SIGN_ALGO_NUM_ECDSA_secp256k1); - const p256PubK = Buffer.from(privateKey.getPublicKeyNist256p1().uncompressed().data()) - .toString('hex') - .replace(/^04/, ''); - const secp256PubK = Buffer.from(privateKey.getPublicKeySecp256k1(false).data()) - .toString('hex') - .replace(/^04/, ''); return { P256: { pubK: p256PubK, @@ -80,19 +77,8 @@ const pk2PubKey = async (pk: string): Promise => { * @returns the public key */ const getPublicKeyFromPrivateKey = async (pk: string, signAlgo: number): Promise => { - const { PrivateKey } = await initWasm(); - const privateKey = PrivateKey.createWithData(Buffer.from(pk, 'hex')); - if (signAlgo === SIGN_ALGO_NUM_ECDSA_P256) { - return Buffer.from(privateKey.getPublicKeyNist256p1().uncompressed().data()) - .toString('hex') - .replace(/^04/, ''); - } else if (signAlgo === SIGN_ALGO_NUM_ECDSA_secp256k1) { - return Buffer.from(privateKey.getPublicKeySecp256k1(false).data()) - .toString('hex') - .replace(/^04/, ''); - } else { - throw new Error(`Unsupported signAlgo: ${signAlgo}`); - } + // Use Noble implementation + return getPublicKeyFromPrivateKeyNoble(pk, signAlgo); }; const formPubKey = async (pubKey: string): Promise => { @@ -122,28 +108,8 @@ const seedWithPathAndPhrase2PublicPrivateKey = async ( derivationPath: string = FLOW_BIP44_PATH, passphrase: string = '' ): Promise => { - const { HDWallet, Curve } = await initWasm(); - - const wallet = HDWallet.createWithMnemonic(seed, passphrase); - const p256PK = wallet.getKeyByCurve(Curve.nist256p1, derivationPath); - const p256PubK = Buffer.from(p256PK.getPublicKeyNist256p1().uncompressed().data()) - .toString('hex') - .replace(/^04/, ''); - const SECP256PK = wallet.getKeyByCurve(Curve.secp256k1, derivationPath); - const secp256PubK = Buffer.from(SECP256PK.getPublicKeySecp256k1(false).data()) - .toString('hex') - .replace(/^04/, ''); - const keyTuple: PublicPrivateKeyTuple = { - P256: { - pubK: p256PubK, - pk: Buffer.from(p256PK.data()).toString('hex'), - }, - SECP256K1: { - pubK: secp256PubK, - pk: Buffer.from(SECP256PK.data()).toString('hex'), - }, - }; - return keyTuple; + // Use Noble implementation + return seedWithPathAndPhrase2PublicPrivateKeyNoble(seed, derivationPath, passphrase); }; /** @@ -170,29 +136,11 @@ const seed2PublicPrivateKey_depreciated = async (seed: string): Promise => { - const { HDWallet, Curve } = await initWasm(); - const path = (await storage.get('temp_path')) || FLOW_BIP44_PATH; const passphrase = (await storage.get('temp_phrase')) || ''; - const wallet = HDWallet.createWithMnemonic(seed, passphrase); - const p256PK = wallet.getKeyByCurve(Curve.nist256p1, path); - const p256PubK = Buffer.from(p256PK.getPublicKeyNist256p1().uncompressed().data()) - .toString('hex') - .replace(/^04/, ''); - const SECP256PK = wallet.getKeyByCurve(Curve.secp256k1, path); - const secp256PubK = Buffer.from(SECP256PK.getPublicKeySecp256k1(false).data()) - .toString('hex') - .replace(/^04/, ''); - return { - P256: { - pubK: p256PubK, - pk: Buffer.from(p256PK.data()).toString('hex'), - }, - SECP256K1: { - pubK: secp256PubK, - pk: Buffer.from(SECP256PK.data()).toString('hex'), - }, - }; + + // Use Noble implementation + return seedWithPathAndPhrase2PublicPrivateKeyNoble(seed, path, passphrase); }; /** * Signs a hex encoded message using the private key @@ -201,13 +149,8 @@ const seed2PublicPrivateKeyTemp = async (seed: string): Promise { - // Other key - const { Hash } = await initWasm(); - const messageHash = - hashAlgo === HASH_ALGO_NUM_SHA3_256 - ? Hash.sha3_256(Buffer.from(messageData, 'hex')) - : Hash.sha256(Buffer.from(messageData, 'hex')); - return messageHash; + // Use Noble implementation + return signMessageHashNoble(hashAlgo, messageData); }; /** @@ -228,71 +171,14 @@ const signWithKey = async ( includeV: boolean = false, isPrehashed: boolean = false ) => { - const { Curve, Hash, PrivateKey } = await initWasm(); - const messageBuffer = Buffer.from(messageHex, 'hex'); - const privateKey = PrivateKey.createWithData(Buffer.from(pk, 'hex')); - - let selectedCurve: typeof Curve.secp256k1 | typeof Curve.nist256p1; // TrustWallet's Curve type - if (signAlgo === SIGN_ALGO_NUM_ECDSA_secp256k1) { - selectedCurve = Curve.secp256k1; - } else if (signAlgo === SIGN_ALGO_NUM_ECDSA_P256) { - selectedCurve = Curve.nist256p1; - } else { - throw new Error(`Unsupported signAlgo: ${signAlgo} - pk: ${pk.substring(0, 10)}`); - } - let digestToSign: Uint8Array = messageBuffer; - if (isPrehashed) { - digestToSign = messageBuffer; - } else { - if (hashAlgo === HASH_ALGO_NUM_SHA3_256) { - digestToSign = Hash.sha3_256(messageBuffer); - } else if (hashAlgo === HASH_ALGO_NUM_SHA2_256) { - digestToSign = Hash.sha256(messageBuffer); - } else { - throw new Error(`Unsupported hashAlgo: ${hashAlgo}`); - } - } - - // Ensure digest is 32 bytes for secp256k1 if prehashed, as privateKey.sign might expect it. - // If not prehashed, Hash.sha256/sha3_256 will produce 32 bytes. - if (isPrehashed && selectedCurve === Curve.secp256k1 && digestToSign.length !== 32) { - throw new Error('Prehashed digest for secp256k1 signing must be 32 bytes long.'); - } - - const signature = privateKey.sign(digestToSign, selectedCurve); - // For secp256k1, privateKey.sign from TrustWallet usually returns r (32) + s (32) + v (1, recId 0/1). - // For nist256p1, it might be DER or just r (32) + s (32). - // The test expects r+s+v (v=recId), so this should align if Wallet Core behaves as such for secp256k1. - if (includeV) { - return Buffer.from(signature).toString('hex'); - } else { - return Buffer.from(signature.subarray(0, signature.length - 1)).toString('hex'); - } + // Use Noble implementation + return signWithKeyNoble(messageHex, signAlgo, hashAlgo, pk, includeV, isPrehashed); }; const verifySignature = async (signature: string, message: unknown) => { try { - const { PublicKey, PublicKeyType, Hash } = await initWasm(); - const scriptsPublicKey = process.env.SCRIPTS_PUBLIC_KEY; - if (!scriptsPublicKey) { - throw new Error('SCRIPTS_PUBLIC_KEY is not set'); - } - - const messageStr = typeof message === 'string' ? message : JSON.stringify(message); - - const messageHash = Hash.sha256(Buffer.from(messageStr, 'utf8')); - const signatureBuffer = Buffer.from(signature, 'hex'); - const pubkeyData = Buffer.from( - '04' + scriptsPublicKey.replace('0x', '').replace(/^04/, ''), - 'hex' - ); - - const pubKey = PublicKey.createWithData(pubkeyData, PublicKeyType.nist256p1Extended); - if (!pubKey) { - throw new Error('Failed to create public key'); - } - - return pubKey.verify(signatureBuffer, messageHash); + // Use Noble implementation + return verifySignatureNoble(signature, message); } catch (error) { consoleError( 'Failed to verify signature:', diff --git a/packages/core/src/utils/modules/trustwallet-crypto.ts b/packages/core/src/utils/modules/trustwallet-crypto.ts new file mode 100644 index 000000000..4adad6952 --- /dev/null +++ b/packages/core/src/utils/modules/trustwallet-crypto.ts @@ -0,0 +1,63 @@ +import { initWasm } from '@trustwallet/wallet-core'; + +import { type PublicPrivateKeyTuple } from '@onflow/flow-wallet-shared/types/key-types'; + +/** + * Derives a key pair from a mnemonic and path using TrustWallet library. + * @param mnemonic - The mnemonic phrase + * @param path - The derivation path + * @param passphrase - Optional passphrase + * @returns The derived key pair + */ +export async function seedWithPathAndPhrase2PublicPrivateKeyTrustWallet( + mnemonic: string, + path: string, + passphrase: string = '' +): Promise { + const { HDWallet, Curve } = await initWasm(); + + const wallet = HDWallet.createWithMnemonic(mnemonic, passphrase); + const p256PK = wallet.getKeyByCurve(Curve.nist256p1, path); + const p256PubK = Buffer.from(p256PK.getPublicKeyNist256p1().uncompressed().data()) + .toString('hex') + .replace(/^04/, ''); + const SECP256PK = wallet.getKeyByCurve(Curve.secp256k1, path); + const secp256PubK = Buffer.from(SECP256PK.getPublicKeySecp256k1(false).data()) + .toString('hex') + .replace(/^04/, ''); + const keyTuple: PublicPrivateKeyTuple = { + P256: { + pubK: p256PubK, + pk: Buffer.from(p256PK.data()).toString('hex'), + }, + SECP256K1: { + pubK: secp256PubK, + pk: Buffer.from(SECP256PK.data()).toString('hex'), + }, + }; + return keyTuple; +} + +/** + * Get public key from private key for a specific algorithm using TrustWallet + * @param pk - Private key in hex + * @param signAlgo - Signing algorithm (P256 or SECP256K1) + * @returns Public key in hex + */ +export async function getPublicKeyFromPrivateKeyTrustWallet( + pk: string, + signAlgo: 'P256' | 'SECP256K1' +): Promise { + const { PrivateKey } = await initWasm(); + + const privateKeyData = Buffer.from(pk, 'hex'); + const privateKey = PrivateKey.createWithData(privateKeyData); + + if (signAlgo === 'P256') { + const pubKey = privateKey.getPublicKeyNist256p1().uncompressed().data(); + return Buffer.from(pubKey).toString('hex').replace(/^04/, ''); + } else { + const pubKey = privateKey.getPublicKeySecp256k1(false).data(); + return Buffer.from(pubKey).toString('hex').replace(/^04/, ''); + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 0fad89a2f..9527f9f53 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "ES2022", "module": "ESNext", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "moduleResolution": "bundler", "declaration": true, "declarationMap": true, @@ -10,25 +10,34 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "noImplicitAny": false, + "useUnknownInCatchVariables": false, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "outDir": "./dist", - "rootDir": "./", "composite": true, "paths": { "@onflow/flow-wallet-shared/*": ["../shared/src/*"], "@onflow/flow-wallet-extension-shared/*": ["../extension-shared/src/*"], "@onflow/flow-wallet-data-model/*": ["../data-model/src/*"], "@/*": ["./src/*"] - } + }, + "types": ["chrome", "node"] }, + "include": ["src/**/*", "tsup.config.ts", "vitest.config.ts", "vitest.init.ts"], "exclude": ["node_modules", "dist"], "references": [ { "path": "../data-model" + }, + { + "path": "../extension-shared" + }, + { + "path": "../shared" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2bd5725d..c7720cd3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,9 @@ importers: '@mui/system': specifier: ^7.2.0 version: 7.2.0(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1))(@types/react@18.3.23)(react@18.3.1) - '@noble/secp256k1': - specifier: ^1.7.2 - version: 1.7.2 + '@noble/curves': + specifier: ^1.9.3 + version: 1.9.3 '@onflow/fcl': specifier: ^1.19.0 version: 1.19.0(@types/react@18.3.23)(google-protobuf@3.21.4)(jiti@2.4.2)(postcss@8.5.6)(react@18.3.1)(ts-node@10.9.2(@swc/core@1.12.14)(@types/node@22.16.4)(typescript@5.8.3))(tsx@4.20.3)(typescript@5.8.3)(zod@3.25.76) @@ -113,9 +113,6 @@ importers: '@reown/walletkit': specifier: ^1.2.8 version: 1.2.8(typescript@5.8.3)(zod@3.25.76) - '@trustwallet/wallet-core': - specifier: ^4.3.6 - version: 4.3.6 '@tsparticles/engine': specifier: ^3.8.1 version: 3.8.1 @@ -300,6 +297,12 @@ importers: '@playwright/test': specifier: ^1.53.2 version: 1.54.1 + '@scure/bip32': + specifier: ^1.7.0 + version: 1.7.0 + '@scure/bip39': + specifier: ^1.6.0 + version: 1.6.0 '@storybook/addon-docs': specifier: ^9.0.16 version: 9.0.17(@types/react@18.3.23)(storybook@9.0.17(@testing-library/dom@10.4.0)(prettier@3.6.2)) @@ -486,6 +489,9 @@ importers: url-loader: specifier: ^4.1.1 version: 4.1.1(file-loader@6.2.0(webpack@5.100.2))(webpack@5.100.2) + viem: + specifier: ^2.32.0 + version: 2.32.0(typescript@5.8.3)(zod@3.25.76) vitest: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.16.4)(jiti@2.4.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0) @@ -504,6 +510,12 @@ importers: packages/core: dependencies: + '@noble/ciphers': + specifier: ^1.3.0 + version: 1.3.0 + '@noble/curves': + specifier: ^1.9.3 + version: 1.9.3 '@onflow/flow-wallet-data-model': specifier: workspace:* version: link:../data-model @@ -513,6 +525,12 @@ importers: '@onflow/flow-wallet-shared': specifier: workspace:* version: link:../shared + '@scure/bip32': + specifier: ^1.7.0 + version: 1.7.0 + '@scure/bip39': + specifier: ^1.6.0 + version: 1.6.0 bignumber.js: specifier: ^9.1.2 version: 9.3.1 @@ -2089,6 +2107,10 @@ packages: resolution: {integrity: sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==} engines: {node: ^14.21.3 || >=16} + '@noble/curves@1.9.3': + resolution: {integrity: sha512-NiHFh8qtZRREtY0Bpup+xpmLnB0bn9UAtj8CARBc2x1zjpVLDC84u+Bvy2+uaSgA3AmMP9zsacMZT1echgVAdQ==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.3.2': resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} @@ -2105,9 +2127,6 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@noble/secp256k1@1.7.2': - resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2624,9 +2643,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@trustwallet/wallet-core@4.3.6': - resolution: {integrity: sha512-X+n2CzDhIfUtnQtqqM3Su6XmBdzUijMu8uQEnA9yQWfOv7d33LfaQ9vNbzvpFk4I52K7n6AWLaylM1unuIuTJQ==} - '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -6211,6 +6227,14 @@ packages: typescript: optional: true + ox@0.8.1: + resolution: {integrity: sha512-e+z5epnzV+Zuz91YYujecW8cF01mzmrUtWotJ0oEPym/G82uccs7q0WDHTYL3eiONbTUEvcZrptAKLgTBD3u2A==} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -7954,6 +7978,14 @@ packages: typescript: optional: true + viem@2.32.0: + resolution: {integrity: sha512-pHwKXQSyEWX+8ttOQJdU5dSBfYd6L9JxARY/Sx0MBj3uF/Zaiqt6o1SbzjFjQXkNzWSgtxK7H89ZI1SMIA2iLQ==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -10105,6 +10137,10 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/curves@1.9.3': + dependencies: + '@noble/hashes': 1.8.0 + '@noble/hashes@1.3.2': {} '@noble/hashes@1.4.0': {} @@ -10113,8 +10149,6 @@ snapshots: '@noble/hashes@1.8.0': {} - '@noble/secp256k1@1.7.2': {} - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10503,7 +10537,7 @@ snapshots: '@scure/bip32@1.7.0': dependencies: - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.3 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 @@ -10836,10 +10870,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 - '@trustwallet/wallet-core@4.3.6': - dependencies: - protobufjs: 7.5.3 - '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -15149,7 +15179,22 @@ snapshots: dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.2 + '@noble/curves': 1.9.3 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) + eventemitter3: 5.0.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - zod + + ox@0.8.1(typescript@5.8.3)(zod@3.25.76): + dependencies: + '@adraffy/ens-normalize': 1.11.0 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.3 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 @@ -17054,6 +17099,23 @@ snapshots: - utf-8-validate - zod + viem@2.32.0(typescript@5.8.3)(zod@3.25.76): + dependencies: + '@noble/curves': 1.9.2 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.0.8(typescript@5.8.3)(zod@3.25.76) + isows: 1.0.7(ws@8.18.2) + ox: 0.8.1(typescript@5.8.3)(zod@3.25.76) + ws: 8.18.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + vite-node@3.2.4(@types/node@22.16.4)(jiti@2.4.2)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0): dependencies: cac: 6.7.14