From fd7f6446ea348d9b906c63e30b292b1103c85fa6 Mon Sep 17 00:00:00 2001 From: Gulshan Yadav Date: Sat, 10 Jan 2026 06:03:24 +0530 Subject: [PATCH] feat(web-wallet): add hardware wallet support (Ledger/Trezor) - Add hardware-wallet.ts with Ledger and Trezor integration - Create HardwareWalletConnect.tsx component for wallet selection UI - Add Hardware Wallet section to Settings page - Support WebHID transport for Ledger (Nano S/X/S Plus) - Support Trezor Connect for Trezor Model T/One - Implement hybrid signature flow for hardware wallets: - Ed25519 signed on hardware device (key never leaves device) - Dilithium3 requested from server (for post-quantum protection) Dependencies added: - @ledgerhq/hw-transport-webhid: WebHID transport for Ledger - @trezor/connect-web: Trezor Connect integration Note: Hardware wallets don't support Dilithium3 yet, so the hybrid signature scheme uses server-side Dilithium signing with Ed25519 proof. --- apps/web/Dockerfile | 7 +- apps/web/package.json | 4 +- .../src/components/HardwareWalletConnect.tsx | 239 ++++++++++ apps/web/src/lib/crypto.ts | 3 +- apps/web/src/lib/hardware-wallet.ts | 414 ++++++++++++++++++ apps/web/src/pages/Settings.tsx | 75 ++++ 6 files changed, 738 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/HardwareWalletConnect.tsx create mode 100644 apps/web/src/lib/hardware-wallet.ts diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 2b6a98e..d78114d 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -4,11 +4,14 @@ # Stage 1: Build FROM node:20-alpine AS builder +# Install build dependencies for native modules (usb, etc.) +RUN apk add --no-cache python3 make g++ linux-headers eudev-dev libusb-dev + WORKDIR /app -# Install dependencies +# Install dependencies (--ignore-optional for problematic native deps) COPY package.json ./ -RUN npm install +RUN npm install --ignore-optional || npm install # Copy source and build COPY . . diff --git a/apps/web/package.json b/apps/web/package.json index 50be6bd..3c29072 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,9 @@ "bip39": "^3.1.0", "zustand": "^4.5.0", "qrcode.react": "^3.1.0", - "html5-qrcode": "^2.3.8" + "html5-qrcode": "^2.3.8", + "@ledgerhq/hw-transport-webhid": "^6.29.0", + "@trezor/connect-web": "^9.4.0" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/apps/web/src/components/HardwareWalletConnect.tsx b/apps/web/src/components/HardwareWalletConnect.tsx new file mode 100644 index 0000000..80cb639 --- /dev/null +++ b/apps/web/src/components/HardwareWalletConnect.tsx @@ -0,0 +1,239 @@ +import { useState, useEffect } from 'react'; +import { + createHardwareWallet, + isHardwareWalletSupported, + isWebHIDSupported, + type HardwareWalletType, + type HardwareWalletInfo, + type HardwareWalletAccount, + type HardwareWallet, +} from '../lib/hardware-wallet'; + +interface HardwareWalletConnectProps { + onAccountSelected: (account: HardwareWalletAccount, wallet: HardwareWallet) => void; + onCancel: () => void; +} + +type ConnectionStep = 'select' | 'connecting' | 'accounts' | 'error'; + +export function HardwareWalletConnect({ + onAccountSelected, + onCancel, +}: HardwareWalletConnectProps) { + const [step, setStep] = useState('select'); + const [selectedType, setSelectedType] = useState(null); + const [wallet, setWallet] = useState(null); + const [walletInfo, setWalletInfo] = useState(null); + const [accounts, setAccounts] = useState([]); + const [error, setError] = useState(''); + const [isSupported] = useState(isHardwareWalletSupported()); + + const handleSelectWallet = async (type: HardwareWalletType) => { + setSelectedType(type); + setStep('connecting'); + setError(''); + + try { + const hw = createHardwareWallet(type); + const info = await hw.connect(); + setWallet(hw); + setWalletInfo(info); + + // Get accounts + const accts = await hw.getAccounts(5); + setAccounts(accts); + setStep('accounts'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Connection failed'); + setStep('error'); + } + }; + + const handleSelectAccount = (account: HardwareWalletAccount) => { + if (wallet) { + onAccountSelected(account, wallet); + } + }; + + const handleRetry = () => { + setStep('select'); + setError(''); + if (wallet) { + wallet.disconnect().catch(() => {}); + setWallet(null); + } + }; + + useEffect(() => { + return () => { + // Cleanup on unmount + if (wallet) { + wallet.disconnect().catch(() => {}); + } + }; + }, [wallet]); + + if (!isSupported) { + return ( +
+
+

Hardware Wallet Not Supported

+

+ Your browser doesn't support hardware wallet connections. Please use a + desktop browser like Chrome, Edge, or Brave. +

+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

Connect Hardware Wallet

+ +
+ + {/* Step: Select Wallet Type */} + {step === 'select' && ( +
+

+ Select your hardware wallet to connect securely. +

+ + {/* Ledger */} + + + {/* Trezor */} + + +

+ Your private keys never leave your hardware wallet. Only the + Ed25519 signature is requested. +

+
+ )} + + {/* Step: Connecting */} + {step === 'connecting' && ( +
+
+

Connecting to {selectedType}...

+

+ {selectedType === 'ledger' + ? 'Please connect your Ledger and open the Synor app.' + : 'Please follow the instructions in the Trezor popup.'} +

+
+ )} + + {/* Step: Select Account */} + {step === 'accounts' && ( +
+ {walletInfo && ( +
+
+ + Connected: {walletInfo.model} + {walletInfo.firmwareVersion && ` (v${walletInfo.firmwareVersion})`} + +
+ )} + +

+ Select an account to use with Synor Wallet: +

+ +
+ {accounts.map((account, index) => ( + + ))} +
+ + +
+ )} + + {/* Step: Error */} + {step === 'error' && ( +
+
+ + + +
+

Connection Failed

+

{error}

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/lib/crypto.ts b/apps/web/src/lib/crypto.ts index 7d502e1..732f749 100644 --- a/apps/web/src/lib/crypto.ts +++ b/apps/web/src/lib/crypto.ts @@ -224,9 +224,10 @@ async function deriveEncryptionKey( salt: Uint8Array ): Promise { const encoder = new TextEncoder(); + const passwordBytes = encoder.encode(password); const keyMaterial = await crypto.subtle.importKey( 'raw', - encoder.encode(password), + passwordBytes.buffer as ArrayBuffer, 'PBKDF2', false, ['deriveKey'] diff --git a/apps/web/src/lib/hardware-wallet.ts b/apps/web/src/lib/hardware-wallet.ts new file mode 100644 index 0000000..31bee8e --- /dev/null +++ b/apps/web/src/lib/hardware-wallet.ts @@ -0,0 +1,414 @@ +/** + * Hardware Wallet Integration for Synor Web Wallet + * + * Supports Ledger and Trezor hardware wallets for secure Ed25519 signing. + * + * ## Hybrid Signature Flow + * + * Since hardware wallets don't yet support Dilithium3, hybrid signatures work as: + * 1. Ed25519 signature: Created on hardware wallet (key never leaves device) + * 2. Dilithium3 signature: Requested from server using Ed25519 proof + * + * This maintains hardware wallet security for classical signatures while + * providing post-quantum protection via server-side Dilithium. + * + * ## Supported Devices + * + * - **Ledger Nano S/X/S Plus**: Via WebHID transport + * - **Trezor Model T/One**: Via Trezor Connect + */ + +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { blake3 } from '@noble/hashes/blake3'; + +// ==================== Types ==================== + +export type HardwareWalletType = 'ledger' | 'trezor'; + +export interface HardwareWalletInfo { + type: HardwareWalletType; + model: string; + firmwareVersion?: string; + connected: boolean; +} + +export interface HardwareWalletAccount { + path: string; + publicKey: Uint8Array; + address: string; +} + +export interface HardwareWallet { + type: HardwareWalletType; + connect(): Promise; + disconnect(): Promise; + getAccounts(count?: number): Promise; + signMessage(path: string, message: Uint8Array): Promise; + isConnected(): boolean; +} + +// ==================== Ledger Implementation ==================== + +class LedgerWallet implements HardwareWallet { + type: HardwareWalletType = 'ledger'; + private transport: any = null; + + async connect(): Promise { + try { + // Dynamic import to avoid loading until needed + const TransportWebHID = (await import('@ledgerhq/hw-transport-webhid')).default; + + // Request permission and connect + this.transport = await TransportWebHID.create(); + + // Get app info + const appInfo = await this.getAppInfo(); + + return { + type: 'ledger', + model: appInfo.model || 'Ledger Device', + firmwareVersion: appInfo.version, + connected: true, + }; + } catch (error) { + throw new Error( + `Failed to connect to Ledger: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + async disconnect(): Promise { + if (this.transport) { + await this.transport.close(); + this.transport = null; + } + } + + isConnected(): boolean { + return this.transport !== null; + } + + private async getAppInfo(): Promise<{ model: string; version: string }> { + // Send APDU to get app version + // This is a generic command that most Ledger apps support + try { + await this.transport.send(0xb0, 0x01, 0x00, 0x00); + // Response format varies by app, use defaults + return { + model: 'Ledger Nano', + version: '1.0.0', + }; + } catch { + return { model: 'Ledger Nano', version: 'Unknown' }; + } + } + + async getAccounts(count: number = 5): Promise { + if (!this.transport) { + throw new Error('Ledger not connected'); + } + + const accounts: HardwareWalletAccount[] = []; + + // ED25519 BIP32 path for Synor: m/44'/397'/account'/0/0 + // 397 is an example coin type - in production, register with SLIP-44 + const SYNOR_COIN_TYPE = 397; + + for (let i = 0; i < count; i++) { + const path = `m/44'/${SYNOR_COIN_TYPE}'/${i}'/0/0`; + + try { + // Get public key from Ledger + // APDU command for ED25519 public key derivation + const pathBuffer = this.buildPathBuffer(path); + const response = await this.transport.send( + 0xe0, // CLA + 0x02, // INS: GET_PUBLIC_KEY + 0x00, // P1: Don't display + 0x00, // P2 + pathBuffer + ); + + // Extract public key (32 bytes for Ed25519) + const publicKey = response.slice(1, 33); + const address = this.publicKeyToAddress(publicKey); + + accounts.push({ path, publicKey, address }); + } catch (error) { + console.warn(`Failed to get account ${i}:`, error); + break; + } + } + + return accounts; + } + + async signMessage(path: string, message: Uint8Array): Promise { + if (!this.transport) { + throw new Error('Ledger not connected'); + } + + const pathBuffer = this.buildPathBuffer(path); + + // Build sign transaction APDU + const data = Buffer.concat([pathBuffer, Buffer.from(message)]); + + const response = await this.transport.send( + 0xe0, // CLA + 0x04, // INS: SIGN + 0x00, // P1 + 0x00, // P2 + data + ); + + // Extract signature (64 bytes for Ed25519) + return new Uint8Array(response.slice(0, 64)); + } + + private buildPathBuffer(path: string): Buffer { + const elements = path.split('/').slice(1); // Remove 'm' + const buffer = Buffer.alloc(1 + elements.length * 4); + buffer.writeUInt8(elements.length, 0); + + elements.forEach((element, index) => { + const hardened = element.endsWith("'"); + const value = parseInt(element.replace("'", ''), 10); + buffer.writeUInt32BE(hardened ? value + 0x80000000 : value, 1 + index * 4); + }); + + return buffer; + } + + private publicKeyToAddress(publicKey: Uint8Array): string { + const hash = blake3(publicKey); + const addressBytes = hash.slice(0, 20); + return `synor:qz${bytesToHex(addressBytes)}`; + } +} + +// ==================== Trezor Implementation ==================== + +class TrezorWallet implements HardwareWallet { + type: HardwareWalletType = 'trezor'; + private connected: boolean = false; + private TrezorConnect: any = null; + + async connect(): Promise { + try { + // Dynamic import + const { default: TrezorConnect } = await import('@trezor/connect-web'); + this.TrezorConnect = TrezorConnect; + + // Initialize Trezor Connect + await TrezorConnect.init({ + manifest: { + appName: 'Synor Wallet', + email: 'support@synor.cc', + appUrl: 'https://wallet.synor.cc', + }, + lazyLoad: true, + }); + + // Get device features to verify connection + const result = await TrezorConnect.getFeatures(); + + if (!result.success) { + throw new Error(result.payload.error || 'Failed to connect'); + } + + this.connected = true; + + return { + type: 'trezor', + model: result.payload.model || 'Trezor Device', + firmwareVersion: `${result.payload.major_version}.${result.payload.minor_version}.${result.payload.patch_version}`, + connected: true, + }; + } catch (error) { + throw new Error( + `Failed to connect to Trezor: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + async disconnect(): Promise { + if (this.TrezorConnect) { + await this.TrezorConnect.dispose(); + } + this.connected = false; + this.TrezorConnect = null; + } + + isConnected(): boolean { + return this.connected; + } + + async getAccounts(count: number = 5): Promise { + if (!this.TrezorConnect) { + throw new Error('Trezor not connected'); + } + + const accounts: HardwareWalletAccount[] = []; + const SYNOR_COIN_TYPE = 397; + + // Get multiple public keys in a batch + const bundle = Array.from({ length: count }, (_, i) => ({ + path: `m/44'/${SYNOR_COIN_TYPE}'/${i}'/0/0`, + showOnTrezor: false, + })); + + const result = await this.TrezorConnect.getPublicKey({ bundle }); + + if (!result.success) { + throw new Error(result.payload.error || 'Failed to get public keys'); + } + + for (const item of result.payload) { + const publicKey = new Uint8Array(Buffer.from(item.publicKey, 'hex')); + const address = this.publicKeyToAddress(publicKey); + accounts.push({ + path: item.path, + publicKey, + address, + }); + } + + return accounts; + } + + async signMessage(path: string, message: Uint8Array): Promise { + if (!this.TrezorConnect) { + throw new Error('Trezor not connected'); + } + + const result = await this.TrezorConnect.signMessage({ + path, + message: bytesToHex(message), + coin: 'SYNOR', + }); + + if (!result.success) { + throw new Error(result.payload.error || 'Failed to sign'); + } + + return new Uint8Array(Buffer.from(result.payload.signature, 'hex')); + } + + private publicKeyToAddress(publicKey: Uint8Array): string { + const hash = blake3(publicKey); + const addressBytes = hash.slice(0, 20); + return `synor:qz${bytesToHex(addressBytes)}`; + } +} + +// ==================== Factory and Utilities ==================== + +/** + * Create a hardware wallet instance. + */ +export function createHardwareWallet(type: HardwareWalletType): HardwareWallet { + switch (type) { + case 'ledger': + return new LedgerWallet(); + case 'trezor': + return new TrezorWallet(); + default: + throw new Error(`Unsupported hardware wallet type: ${type}`); + } +} + +/** + * Check if WebHID is supported (required for Ledger). + */ +export function isWebHIDSupported(): boolean { + return 'hid' in navigator; +} + +/** + * Check if hardware wallets are supported in this browser. + */ +export function isHardwareWalletSupported(): boolean { + // WebHID for Ledger, or WebUSB fallback + // Trezor Connect works in most browsers via popup + return isWebHIDSupported() || 'usb' in navigator; +} + +/** + * Detect connected hardware wallets. + * Returns a list of detected wallet types. + */ +export async function detectHardwareWallets(): Promise { + const detected: HardwareWalletType[] = []; + + // Check for Ledger via WebHID + if (isWebHIDSupported()) { + try { + const devices = await (navigator as any).hid.getDevices(); + const ledgerDevices = devices.filter( + (d: any) => d.vendorId === 0x2c97 // Ledger vendor ID + ); + if (ledgerDevices.length > 0) { + detected.push('ledger'); + } + } catch { + // Permission not granted yet + } + } + + // Trezor detection would require attempting connection + // Since it uses a popup flow, we just indicate it's available + detected.push('trezor'); + + return detected; +} + +// ==================== Hybrid Signature Support ==================== + +/** + * Create a hybrid signature using hardware wallet for Ed25519. + * + * Flow: + * 1. Sign message with hardware wallet (Ed25519) + * 2. Request Dilithium signature from server (using Ed25519 proof) + * + * @param message - Message to sign + * @param wallet - Connected hardware wallet + * @param accountPath - BIP32 derivation path + * @param rpcUrl - Synor node RPC URL for Dilithium signing + */ +export async function createHybridSignatureHardware( + message: Uint8Array, + wallet: HardwareWallet, + accountPath: string, + rpcUrl: string +): Promise<{ ed25519: Uint8Array; dilithium: Uint8Array }> { + // Sign with hardware wallet (Ed25519) + const ed25519Signature = await wallet.signMessage(accountPath, message); + + // Request Dilithium signature from server + // The server verifies the Ed25519 signature and returns a Dilithium signature + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'wallet_signDilithiumForHardware', + params: { + message: bytesToHex(message), + ed25519Signature: bytesToHex(ed25519Signature), + accountPath, + }, + id: 1, + }), + }); + + const result = await response.json(); + if (result.error) { + throw new Error(`Dilithium signing failed: ${result.error.message}`); + } + + return { + ed25519: ed25519Signature, + dilithium: hexToBytes(result.result.signature), + }; +} diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 6e24316..dbc70ff 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -1,6 +1,12 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useWalletStore, type Network } from '../store/wallet'; +import { HardwareWalletConnect } from '../components/HardwareWalletConnect'; +import { + isHardwareWalletSupported, + type HardwareWalletAccount, + type HardwareWallet, +} from '../lib/hardware-wallet'; export default function SettingsPage() { const { @@ -15,6 +21,19 @@ export default function SettingsPage() { const [customEndpoint, setCustomEndpoint] = useState(rpcEndpoint); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [showHardwareConnect, setShowHardwareConnect] = useState(false); + const [connectedHardwareWallet, setConnectedHardwareWallet] = useState<{ + account: HardwareWalletAccount; + wallet: HardwareWallet; + } | null>(null); + + const handleHardwareWalletConnected = ( + account: HardwareWalletAccount, + wallet: HardwareWallet + ) => { + setConnectedHardwareWallet({ account, wallet }); + setShowHardwareConnect(false); + }; const handleNetworkChange = (newNetwork: Network) => { setNetwork(newNetwork); @@ -108,6 +127,54 @@ export default function SettingsPage() {

+ {/* Hardware Wallet */} +
+

Hardware Wallet

+ {connectedHardwareWallet ? ( +
+
+
+
+
+ {connectedHardwareWallet.wallet.type === 'ledger' ? 'Ledger' : 'Trezor'} Connected +
+
+ {connectedHardwareWallet.account.address} +
+
+
+ +
+ ) : ( +
+

+ Connect a Ledger or Trezor hardware wallet for enhanced security. + Your private keys never leave the device. +

+ + {!isHardwareWalletSupported() && ( +

+ Use Chrome, Edge, or Brave on desktop for hardware wallet support. +

+ )} +
+ )} +
+ {/* Security */}

Security

@@ -187,6 +254,14 @@ export default function SettingsPage() {
Synor Web Wallet v0.1.0
+ + {/* Hardware Wallet Connect Modal */} + {showHardwareConnect && ( + setShowHardwareConnect(false)} + /> + )}
); }