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)}
+ />
+ )}
);
}