diff --git a/yarn-project/pxe/src/pxe.ts b/yarn-project/pxe/src/pxe.ts index 9dfd174e1a8a..e37cb30095eb 100644 --- a/yarn-project/pxe/src/pxe.ts +++ b/yarn-project/pxe/src/pxe.ts @@ -60,6 +60,7 @@ import { } from './contract_function_simulator/contract_function_simulator.js'; import { readCurrentClassId } from './contract_function_simulator/oracle/private_execution.js'; import { ProxiedContractStoreFactory } from './contract_function_simulator/proxied_contract_data_source.js'; +import { ProxiedNodeFactory } from './contract_function_simulator/proxied_node.js'; import { PXEDebugUtils } from './debug/pxe_debug_utils.js'; import { enrichPublicSimulationError, enrichSimulationError } from './error_enriching.js'; import { PrivateEventFilterValidator } from './events/private_event_filter_validator.js'; @@ -199,7 +200,7 @@ export class PXE { this.noteStore, this.keyStore, this.addressStore, - this.node, + ProxiedNodeFactory.create(this.node), this.anchorBlockStore, this.senderTaggingStore, this.recipientTaggingStore, diff --git a/yarn-project/wallet-sdk/README.md b/yarn-project/wallet-sdk/README.md index 4bd1358b923d..852eb8b8f781 100644 --- a/yarn-project/wallet-sdk/README.md +++ b/yarn-project/wallet-sdk/README.md @@ -9,6 +9,7 @@ All types and utilities needed for wallet integration are exported from `@aztec/ ```typescript import type { ChainInfo, + ConnectRequest, DiscoveryRequest, DiscoveryResponse, WalletInfo, @@ -18,15 +19,30 @@ import type { import { ChainInfoSchema, WalletSchema, jsonStringify } from '@aztec/wallet-sdk/manager'; ``` +Cryptographic utilities for secure channel establishment are exported from `@aztec/wallet-sdk/crypto`: + +```typescript +import type { EncryptedPayload, ExportedPublicKey } from '@aztec/wallet-sdk/crypto'; +import { + decrypt, + deriveSharedKey, + encrypt, + exportPublicKey, + generateKeyPair, + importPublicKey, +} from '@aztec/wallet-sdk/crypto'; +``` + ## Overview -The Wallet SDK uses a **request-based discovery** model: +The Wallet SDK uses a **request-based discovery** model with **end-to-end encryption**: 1. **dApp requests wallets** for a specific chain/version via `WalletManager.getAvailableWallets({ chainInfo })` 2. **SDK broadcasts** a discovery message with chain information -3. **Your wallet responds** ONLY if it supports that specific network +3. **Your wallet responds** with its ECDH public key ONLY if it supports that specific network 4. **dApp receives** only compatible wallets -5. **dApp calls wallet methods** which your wallet handles and responds to +5. **dApp establishes secure channel** via ECDH key exchange (see [Secure Channel](#secure-channel)) +6. **All subsequent communication** is encrypted using AES-256-GCM ### Transport Mechanisms @@ -116,7 +132,19 @@ If your wallet supports the network, respond with your wallet information: ```typescript import { jsonStringify } from '@aztec/wallet-sdk/manager'; -function respondToDiscovery(requestId: string) { +// Your wallet should generate a key pair on initialization. +// This keypair should be recreated each session +let walletKeyPair: CryptoKeyPair; + +async function initializeWallet() { + // Generate ECDH key pair for secure channel establishment + walletKeyPair = await generateKeyPair(); +} + +async function respondToDiscovery(requestId: string) { + // Export the public key for sharing with dApps + const publicKey = await exportPublicKey(walletKeyPair.publicKey); + const response = { type: 'aztec-wallet-discovery-response', requestId, @@ -125,6 +153,7 @@ function respondToDiscovery(requestId: string) { name: 'My Aztec Wallet', // Display name icon: 'https://example.com/icon.png', // Optional icon URL version: '1.0.0', // Wallet version + publicKey, // ECDH public key for secure channel (required) }, }; @@ -142,154 +171,184 @@ function respondToDiscovery(requestId: string) { - Both the SDK and wallets must parse incoming JSON strings - Always use `jsonStringify` from `@aztec/foundation/json-rpc` for sending messages - Always parse incoming messages with `JSON.parse` and the proper schemas +- The `publicKey` field is required for secure channel establishment -## Message Format +## Secure Channel -### Wallet Method Request +After discovery, the dApp establishes a secure encrypted channel with your wallet using ECDH key exchange and AES-256-GCM encryption. This ensures all wallet method calls and responses are encrypted end-to-end. -After discovery, dApps will call wallet methods. These arrive as: +### Security Model -```typescript -{ - type: string, // Wallet method name from the Wallet interface - messageId: string, // UUID for tracking this request - args: unknown[], // Method arguments - chainInfo: { - chainId: Fr, // Same chain that was used in discovery - version: Fr - }, - appId: string, // Application identifier - walletId: string // Your wallet's ID (from discovery response) -} -``` +- **ECDH Key Exchange**: Uses P-256 (secp256r1) elliptic curve for key agreement +- **AES-256-GCM Encryption**: All messages after channel establishment are encrypted +- **Per-Session Keys**: Each connection derives a unique shared secret +- **MessageChannel (Extension wallets)**: Uses a private MessagePort for communication, not visible to other page scripts -Example method calls: +### 1. Handle Connection Requests -- `type: 'getAccounts'` - Get list of accounts -- `type: 'getChainInfo'` - Get chain information -- `type: 'sendTx'` - Send a transaction -- `type: 'registerContract'` - Register a contract instance - -### Wallet Method Response - -Your wallet must respond with: +When a dApp connects, it sends a `ConnectRequest` containing its ECDH public key: ```typescript -{ - messageId: string, // MUST match the request's messageId - result?: unknown, // Method result (if successful) - error?: unknown, // Error (if failed) - walletId: string // Your wallet's ID +interface ConnectRequest { + type: 'aztec-wallet-connect'; + walletId: string; // Your wallet's ID + appId: string; // Application identifier + publicKey: ExportedPublicKey; // dApp's ECDH public key } ``` -## Handling Wallet Methods - -### 1. Set Up Message Listener - **Extension wallet example:** ```typescript -window.addEventListener('message', event => { - if (event.source !== window) { - return; - } +import { decrypt, deriveSharedKey, encrypt, importPublicKey } from '@aztec/wallet-sdk/crypto'; - let data; - try { - data = JSON.parse(event.data); - } catch { - return; // Not a valid JSON message - } +// Store connections by appId +const connections = new Map(); - // Handle discovery - if (data.type === 'aztec-wallet-discovery') { - handleDiscovery(data); - return; - } +window.addEventListener('message', async event => { + if (event.source !== window) return; + + const data = JSON.parse(event.data); - // Handle wallet methods - if (data.messageId && data.type && data.walletId === 'my-aztec-wallet') { - handleWalletMethod(data); + if (data.type === 'aztec-wallet-connect') { + await handleConnect(data, event.ports[0]); } }); -// Using WebSocket: -// websocket.on('message', (message) => { -// const data = JSON.parse(message); -// if (data.type === 'aztec-wallet-discovery') { -// handleDiscovery(data); -// } else if (data.messageId && data.type) { -// handleWalletMethod(data); -// } -// }); +async function handleConnect(request: ConnectRequest, port: MessagePort) { + // Import dApp's public key + const dappPublicKey = await importPublicKey(request.publicKey); + + // Derive shared secret using our private key and dApp's public key + const sharedKey = await deriveSharedKey(walletKeyPair.privateKey, dappPublicKey); + + // Store the connection + connections.set(request.appId, { sharedKey }); + + // Set up encrypted message handler on the MessagePort + port.onmessage = async event => { + await handleEncryptedMessage(request.appId, event.data); + }; + + port.start(); +} ``` -### 2. Route to Wallet Implementation +### 2. Handle Encrypted Messages + +All wallet method calls arrive as encrypted payloads: ```typescript -import { ChainInfoSchema } from '@aztec/wallet-sdk/manager'; +interface EncryptedPayload { + iv: string; // Base64-encoded initialization vector + ciphertext: string; // Base64-encoded encrypted data +} +``` -async function handleWalletMethod(message: any) { - const { type, messageId, args, chainInfo, appId, walletId } = message; +Decrypt incoming messages and encrypt responses: - try { - // Parse and validate chain info - const parsedChainInfo = ChainInfoSchema.parse(chainInfo); +```typescript +async function handleEncryptedMessage(appId: string, encrypted: EncryptedPayload) { + const connection = connections.get(appId); + if (!connection) { + console.error('Unknown connection'); + return; + } - // Get the wallet instance for this chain - const wallet = await getWalletForChain(parsedChainInfo); + try { + // Decrypt the incoming message + const message = await decrypt(connection.sharedKey, encrypted); - // Verify the method exists on the Wallet interface - if (typeof wallet[type] !== 'function') { - throw new Error(`Unknown wallet method: ${type}`); - } + const { type, messageId, args, chainInfo, walletId } = message; - // Call the wallet method + // Process the wallet method call + const wallet = await getWalletForChain(chainInfo); const result = await wallet[type](...args); - // Send success response - sendResponse(messageId, walletId, result); + // Create response + const response: WalletResponse = { + messageId, + result, + walletId, + }; + + // Encrypt and send the response + const encryptedResponse = await encrypt(connection.sharedKey, response); + sendEncryptedResponse(appId, encryptedResponse); } catch (error) { - // Send error response - sendError(messageId, walletId, error); + // Send encrypted error response + const errorResponse: WalletResponse = { + messageId: message?.messageId ?? '', + error: { message: error.message }, + walletId: message?.walletId ?? '', + }; + + const encryptedError = await encrypt(connection.sharedKey, errorResponse); + sendEncryptedResponse(appId, encryptedError); } } ``` -### 3. Send Response +### 3. Extension Wallet Architecture -**Extension wallet example:** +For browser extension wallets, the recommended architecture separates concerns: -```typescript -import { jsonStringify } from '@aztec/wallet-sdk/manager'; +``` +┌─────────────┐ window.postMessage ┌─────────────────┐ browser.runtime ┌──────────────────┐ +│ dApp │◄────────────────────────►│ Content Script │◄────────────────────►│ Background Script│ +│ (web page) │ (discovery only) │ (message relay)│ (encrypted msgs) │ (decrypt+process)│ +└─────────────┘ └─────────────────┘ └──────────────────┘ + │ │ + │ MessagePort (private channel) │ + └────────────────────────────────────────────┘ + (encrypted wallet method calls) +``` -function sendResponse(messageId: string, walletId: string, result: unknown) { - const response = { - messageId, - result, - walletId, - }; +**Security benefits:** - // Send as JSON string - window.postMessage(jsonStringify(response), '*'); -} +- Content script never has access to private keys or shared secrets +- All cryptographic operations happen in the background script (service worker) +- MessagePort provides a private channel not visible to other page scripts +- Only the initial connection handshake uses `window.postMessage` -function sendError(messageId: string, walletId: string, error: Error) { - const response = { - messageId, - error: { - message: error.message, - stack: error.stack, - }, - walletId, - }; +## Message Format - window.postMessage(jsonStringify(response), '*'); +### Wallet Method Request + +After discovery, dApps will call wallet methods. These arrive as: + +```typescript +{ + type: string, // Wallet method name from the Wallet interface + messageId: string, // UUID for tracking this request + args: unknown[], // Method arguments + chainInfo: { + chainId: Fr, // Same chain that was used in discovery + version: Fr + }, + appId: string, // Application identifier + walletId: string // Your wallet's ID (from discovery response) } +``` -// Using WebSocket: -// websocket.send(jsonStringify({ messageId, result, walletId })); +Example method calls: + +- `type: 'getAccounts'` - Get list of accounts +- `type: 'getChainInfo'` - Get chain information +- `type: 'sendTx'` - Send a transaction +- `type: 'registerContract'` - Register a contract instance + +### Wallet Method Response + +Your wallet must respond with: + +```typescript +{ + messageId: string, // MUST match the request's messageId + result?: unknown, // Method result (if successful) + error?: unknown, // Error (if failed) + walletId: string // Your wallet's ID +} ``` ## Parsing Messages @@ -331,37 +390,12 @@ Always send error responses with this structure: ### Common Error Scenarios -```typescript -import { ChainInfoSchema } from '@aztec/wallet-sdk/manager'; - -async function handleWalletMethod(message: any) { - const { type, messageId, args, chainInfo, walletId } = message; - - try { - // 1. Parse and validate chain info - const parsedChainInfo = ChainInfoSchema.parse(chainInfo); +Common errors to handle within the encrypted message handler: - // 2. Check network support - if (!isNetworkSupported(parsedChainInfo)) { - throw new Error('Network not supported by wallet'); - } - - // 3. Get wallet instance - const wallet = await getWalletForChain(parsedChainInfo); - - // 4. Validate method exists - if (typeof wallet[type] !== 'function') { - throw new Error(`Unknown wallet method: ${type}`); - } - - // 5. Execute method - const result = await wallet[type](...args); - sendResponse(messageId, walletId, result); - } catch (error) { - sendError(messageId, walletId, error); - } -} -``` +- **Network not supported**: Chain info doesn't match wallet's supported networks +- **Unknown method**: The requested wallet method doesn't exist +- **Invalid arguments**: Method arguments fail validation +- **User rejection**: User declined the transaction or action ### User Rejection Handling diff --git a/yarn-project/wallet-sdk/package.json b/yarn-project/wallet-sdk/package.json index b5775155bc69..283fa3908546 100644 --- a/yarn-project/wallet-sdk/package.json +++ b/yarn-project/wallet-sdk/package.json @@ -6,6 +6,8 @@ "exports": { "./base-wallet": "./dest/base-wallet/index.js", "./providers/extension": "./dest/providers/extension/index.js", + "./crypto": "./dest/crypto.js", + "./types": "./dest/types.js", "./manager": "./dest/manager/index.js" }, "typedocOptions": { diff --git a/yarn-project/wallet-sdk/src/crypto.ts b/yarn-project/wallet-sdk/src/crypto.ts new file mode 100644 index 000000000000..0773e1436844 --- /dev/null +++ b/yarn-project/wallet-sdk/src/crypto.ts @@ -0,0 +1,282 @@ +/** + * Cryptographic utilities for secure wallet communication. + * + * This module provides ECDH key exchange and AES-GCM encryption primitives + * for establishing secure communication channels between dApps and wallet extensions. + * + * The crypto flow: + * 1. Both parties generate ECDH key pairs using {@link generateKeyPair} + * 2. Public keys are exchanged (exported via {@link exportPublicKey}, imported via {@link importPublicKey}) + * 3. Both parties derive the same shared secret using {@link deriveSharedKey} + * 4. Messages are encrypted/decrypted using {@link encrypt} and {@link decrypt} + * + * @example + * ```typescript + * // Party A + * const keyPairA = await generateKeyPair(); + * const publicKeyA = await exportPublicKey(keyPairA.publicKey); + * + * // Party B + * const keyPairB = await generateKeyPair(); + * const publicKeyB = await exportPublicKey(keyPairB.publicKey); + * + * // Exchange public keys, then derive shared secret + * const importedB = await importPublicKey(publicKeyB); + * const sharedKeyA = await deriveSharedKey(keyPairA.privateKey, importedB); + * + * // Encrypt and decrypt + * const encrypted = await encrypt(sharedKeyA, { message: 'hello' }); + * const decrypted = await decrypt(sharedKeyB, encrypted); + * ``` + * + * @packageDocumentation + */ + +/** + * Exported public key in JWK format for transmission over untrusted channels. + * + * Contains only the public components of an ECDH P-256 key, safe to share. + */ +export interface ExportedPublicKey { + /** Key type - always "EC" for elliptic curve */ + kty: string; + /** Curve name - always "P-256" */ + crv: string; + /** X coordinate (base64url encoded) */ + x: string; + /** Y coordinate (base64url encoded) */ + y: string; +} + +/** + * Encrypted message payload containing ciphertext and initialization vector. + * + * Both fields are base64-encoded for safe transmission as JSON. + */ +export interface EncryptedPayload { + /** Initialization vector (base64 encoded, 12 bytes) */ + iv: string; + /** Ciphertext (base64 encoded) */ + ciphertext: string; +} + +/** + * ECDH key pair for secure communication. + * + * The private key should never be exported or transmitted. + * The public key can be exported via {@link exportPublicKey} for exchange. + */ +export interface SecureKeyPair { + /** Public key - safe to share */ + publicKey: CryptoKey; + /** Private key - keep secret, used for key derivation */ + privateKey: CryptoKey; +} + +/** + * Generates an ECDH P-256 key pair for key exchange. + * + * The generated key pair can be used to derive a shared secret with another + * party's public key using {@link deriveSharedKey}. + * + * @returns A new ECDH key pair + * + * @example + * ```typescript + * const keyPair = await generateKeyPair(); + * const publicKey = await exportPublicKey(keyPair.publicKey); + * // Send publicKey to the other party + * ``` + */ +export async function generateKeyPair(): Promise { + const keyPair = await crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256', + }, + true, // extractable (needed to export public key) + ['deriveKey'], + ); + return { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + }; +} + +/** + * Exports a public key to JWK format for transmission. + * + * The exported key contains only public components and is safe to transmit + * over untrusted channels. + * + * @param publicKey - The CryptoKey public key to export + * @returns The public key in JWK format + * + * @example + * ```typescript + * const keyPair = await generateKeyPair(); + * const exported = await exportPublicKey(keyPair.publicKey); + * // exported can be JSON serialized and sent to another party + * ``` + */ +export async function exportPublicKey(publicKey: CryptoKey): Promise { + const jwk = await crypto.subtle.exportKey('jwk', publicKey); + return { + kty: jwk.kty!, + crv: jwk.crv!, + x: jwk.x!, + y: jwk.y!, + }; +} + +/** + * Imports a public key from JWK format. + * + * Used to import the other party's public key for deriving a shared secret. + * + * @param exported - The public key in JWK format + * @returns A CryptoKey that can be used with {@link deriveSharedKey} + * + * @example + * ```typescript + * // Receive exported public key from other party + * const theirPublicKey = await importPublicKey(receivedPublicKey); + * const sharedKey = await deriveSharedKey(myPrivateKey, theirPublicKey); + * ``` + */ +export function importPublicKey(exported: ExportedPublicKey): Promise { + return crypto.subtle.importKey( + 'jwk', + { + kty: exported.kty, + crv: exported.crv, + x: exported.x, + y: exported.y, + }, + { + name: 'ECDH', + namedCurve: 'P-256', + }, + false, + [], + ); +} + +/** + * Derives a shared AES-256-GCM key from ECDH key exchange. + * + * Both parties will derive the same shared key when using their own private key + * and the other party's public key. This is the core of ECDH key agreement. + * + * @param privateKey - Your ECDH private key + * @param publicKey - The other party's ECDH public key + * @returns An AES-256-GCM key for encryption/decryption + * + * @example + * ```typescript + * // Both parties derive the same key + * const sharedKeyA = await deriveSharedKey(privateKeyA, publicKeyB); + * const sharedKeyB = await deriveSharedKey(privateKeyB, publicKeyA); + * // sharedKeyA and sharedKeyB are equivalent + * ``` + */ +export function deriveSharedKey(privateKey: CryptoKey, publicKey: CryptoKey): Promise { + return crypto.subtle.deriveKey( + { + name: 'ECDH', + public: publicKey, + }, + privateKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ); +} + +/** + * Encrypts data using AES-256-GCM. + * + * The data is JSON serialized before encryption. A random 12-byte IV is + * generated for each encryption operation. + * + * AES-GCM provides both confidentiality and authenticity - any tampering + * with the ciphertext will cause decryption to fail. + * + * @param key - The AES-GCM key (from {@link deriveSharedKey}) + * @param data - The data to encrypt (will be JSON serialized) + * @returns The encrypted payload with IV and ciphertext + * + * @example + * ```typescript + * const encrypted = await encrypt(sharedKey, { action: 'transfer', amount: 100 }); + * // encrypted.iv and encrypted.ciphertext are base64 strings + * ``` + */ +export async function encrypt(key: CryptoKey, data: unknown): Promise { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(JSON.stringify(data)); + + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded); + + return { + iv: arrayBufferToBase64(iv), + ciphertext: arrayBufferToBase64(ciphertext), + }; +} + +/** + * Decrypts data using AES-256-GCM. + * + * The decrypted data is JSON parsed before returning. + * + * @typeParam T - The expected type of the decrypted data + * @param key - The AES-GCM key (from {@link deriveSharedKey}) + * @param payload - The encrypted payload from {@link encrypt} + * @returns The decrypted and parsed data + * + * @throws Error if decryption fails (wrong key or tampered ciphertext) + * + * @example + * ```typescript + * const decrypted = await decrypt<{ action: string; amount: number }>(sharedKey, encrypted); + * console.log(decrypted.action); // 'transfer' + * ``` + */ +export async function decrypt(key: CryptoKey, payload: EncryptedPayload): Promise { + const iv = base64ToArrayBuffer(payload.iv); + const ciphertext = base64ToArrayBuffer(payload.ciphertext); + + const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + + const decoded = new TextDecoder().decode(decrypted); + return JSON.parse(decoded) as T; +} + +/** + * Converts ArrayBuffer to base64 string. + * @internal + */ +function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string { + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Converts base64 string to ArrayBuffer. + * @internal + */ +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} diff --git a/yarn-project/wallet-sdk/src/manager/index.ts b/yarn-project/wallet-sdk/src/manager/index.ts index f97dfe9bec89..7585c419dacb 100644 --- a/yarn-project/wallet-sdk/src/manager/index.ts +++ b/yarn-project/wallet-sdk/src/manager/index.ts @@ -9,13 +9,7 @@ export type { } from './types.js'; // Re-export types from providers for convenience -export type { - WalletInfo, - WalletMessage, - WalletResponse, - DiscoveryRequest, - DiscoveryResponse, -} from '../providers/types.js'; +export type { WalletInfo, WalletMessage, WalletResponse, DiscoveryRequest, DiscoveryResponse } from '../types.js'; // Re-export commonly needed utilities for wallet integration export { ChainInfoSchema } from '@aztec/aztec.js/account'; diff --git a/yarn-project/wallet-sdk/src/manager/wallet_manager.ts b/yarn-project/wallet-sdk/src/manager/wallet_manager.ts index 108cdb5694a1..2e9b1c827ab8 100644 --- a/yarn-project/wallet-sdk/src/manager/wallet_manager.ts +++ b/yarn-project/wallet-sdk/src/manager/wallet_manager.ts @@ -54,7 +54,7 @@ export class WalletManager { metadata: { version: ext.version, }, - connect: (appId: string) => Promise.resolve(ExtensionWallet.create(chainInfo, appId, ext.id)), + connect: (appId: string) => ExtensionWallet.create(ext, chainInfo, appId), }); } } diff --git a/yarn-project/wallet-sdk/src/providers/extension/extension_provider.ts b/yarn-project/wallet-sdk/src/providers/extension/extension_provider.ts index 3b4dc5f72ef1..22c19d16e543 100644 --- a/yarn-project/wallet-sdk/src/providers/extension/extension_provider.ts +++ b/yarn-project/wallet-sdk/src/providers/extension/extension_provider.ts @@ -2,7 +2,7 @@ import type { ChainInfo } from '@aztec/aztec.js/account'; import { jsonStringify } from '@aztec/foundation/json-rpc'; import { promiseWithResolvers } from '@aztec/foundation/promise'; -import type { DiscoveryRequest, DiscoveryResponse, WalletInfo } from '../types.js'; +import type { DiscoveryRequest, DiscoveryResponse, WalletInfo } from '../../types.js'; /** * Provider for discovering and managing Aztec wallet extensions diff --git a/yarn-project/wallet-sdk/src/providers/extension/extension_wallet.ts b/yarn-project/wallet-sdk/src/providers/extension/extension_wallet.ts index 261abba31fd4..e5df5cf32a7f 100644 --- a/yarn-project/wallet-sdk/src/providers/extension/extension_wallet.ts +++ b/yarn-project/wallet-sdk/src/providers/extension/extension_wallet.ts @@ -5,29 +5,74 @@ import { type PromiseWithResolvers, promiseWithResolvers } from '@aztec/foundati import { schemaHasMethod } from '@aztec/foundation/schemas'; import type { FunctionsOf } from '@aztec/foundation/types'; -import type { WalletMessage, WalletResponse } from '../types.js'; +import { + type EncryptedPayload, + type ExportedPublicKey, + decrypt, + deriveSharedKey, + encrypt, + exportPublicKey, + generateKeyPair, + importPublicKey, +} from '../../crypto.js'; +import type { ConnectRequest, WalletInfo, WalletMessage, WalletResponse } from '../../types.js'; /** - * Message payload for posting to extension + * Internal type representing a wallet method call before encryption. + * @internal */ type WalletMethodCall = { - /** - * The wallet method name to invoke - */ + /** The wallet method name to invoke */ type: keyof FunctionsOf; - /** - * Arguments to pass to the wallet method - */ + /** Arguments to pass to the wallet method */ args: unknown[]; }; /** * A wallet implementation that communicates with browser extension wallets - * Supports multiple extensions by targeting specific extension IDs + * using a secure encrypted MessageChannel. + * + * This class establishes a private communication channel with a wallet extension + * using the following security mechanisms: + * + * 1. **MessageChannel**: Creates a private communication channel that is not + * visible to other scripts on the page (unlike window.postMessage). + * + * 2. **ECDH Key Exchange**: Uses Elliptic Curve Diffie-Hellman to derive a + * shared secret between the dApp and wallet without transmitting private keys. + * + * 3. **AES-GCM Encryption**: All messages after channel establishment are + * encrypted using AES-256-GCM, providing both confidentiality and authenticity. + * + * @example + * ```typescript + * // Discovery returns wallet info including the wallet's public key + * const wallets = await ExtensionProvider.discoverExtensions(chainInfo); + * const walletInfo = wallets[0]; + * + * // Create a secure connection to the wallet + * const wallet = await ExtensionWallet.create(walletInfo, chainInfo, 'my-dapp'); + * + * // All subsequent calls are encrypted + * const accounts = await wallet.getAccounts(); + * ``` */ export class ExtensionWallet { + /** Map of pending requests awaiting responses, keyed by message ID */ private inFlight = new Map>(); + /** The MessagePort for private communication with the extension */ + private port: MessagePort | null = null; + + /** The derived AES-GCM key for encrypting/decrypting messages */ + private sharedKey: CryptoKey | null = null; + + /** + * Private constructor - use {@link ExtensionWallet.create} to instantiate. + * @param chainInfo - The chain information (chainId and version) + * @param appId - Application identifier for the requesting dApp + * @param extensionId - The unique identifier of the target wallet extension + */ private constructor( private chainInfo: ChainInfo, private appId: string, @@ -36,75 +81,157 @@ export class ExtensionWallet { /** * Creates an ExtensionWallet instance that proxies wallet calls to a browser extension - * @param chainInfo - The chain information (chainId and version) - * @param appId - Application identifier for the requesting dapp - * @param extensionId - Specific extension ID to communicate with - * @returns A Proxy object that implements the Wallet interface + * over a secure encrypted MessageChannel. + * + * The connection process: + * 1. Generates an ECDH key pair for this session + * 2. Derives a shared AES-256 key using the wallet's public key + * 3. Creates a MessageChannel and transfers one port to the extension + * 4. Returns a Proxy that encrypts all wallet method calls + * + * @param walletInfo - The discovered wallet information, including the wallet's ECDH public key + * @param chainInfo - The chain information (chainId and version) for request context + * @param appId - Application identifier used to identify the requesting dApp to the wallet + * @returns A Promise resolving to a Wallet implementation that encrypts all communication + * + * @throws Error if the secure channel cannot be established + * + * @example + * ```typescript + * const wallet = await ExtensionWallet.create( + * walletInfo, + * { chainId: Fr(31337), version: Fr(0) }, + * 'my-defi-app' + * ); + * ``` */ - static create(chainInfo: ChainInfo, appId: string, extensionId: string): Wallet { - const wallet = new ExtensionWallet(chainInfo, appId, extensionId); + static async create(walletInfo: WalletInfo, chainInfo: ChainInfo, appId: string): Promise { + const wallet = new ExtensionWallet(chainInfo, appId, walletInfo.id); - // Set up message listener for responses from extensions - window.addEventListener('message', event => { - if (event.source !== window) { - return; - } + if (!walletInfo.publicKey) { + throw new Error('Wallet does not support secure channel establishment (missing public key)'); + } - let data: WalletResponse; - try { - data = JSON.parse(event.data); - } catch { - return; - } + await wallet.establishSecureChannel(walletInfo.publicKey); - // Ignore request messages (only process responses) - if ('type' in data) { - return; - } + // Create a Proxy that intercepts wallet method calls and forwards them to the extension + return new Proxy(wallet, { + get: (target, prop) => { + if (schemaHasMethod(WalletSchema, prop.toString())) { + return async (...args: unknown[]) => { + const result = await target.postMessage({ + type: prop.toString() as keyof FunctionsOf, + args, + }); + return WalletSchema[prop.toString() as keyof typeof WalletSchema].returnType().parseAsync(result); + }; + } else { + return target[prop as keyof ExtensionWallet]; + } + }, + }) as unknown as Wallet; + } - const { messageId, result, error, walletId: responseWalletId } = data; + /** + * Establishes a secure MessageChannel with ECDH key exchange. + * + * This method performs the cryptographic handshake: + * 1. Generates a new ECDH P-256 key pair for this session + * 2. Imports the wallet's public key and derives a shared secret + * 3. Creates a MessageChannel for private communication + * 4. Sends a connection request with our public key via window.postMessage + * (this is the only public message - subsequent communication uses the private channel) + * + * @param walletExportedPublicKey - The wallet's ECDH public key in JWK format + */ + private async establishSecureChannel(walletExportedPublicKey: ExportedPublicKey): Promise { + const keyPair = await generateKeyPair(); + const exportedPublicKey = await exportPublicKey(keyPair.publicKey); + + const walletPublicKey = await importPublicKey(walletExportedPublicKey); + this.sharedKey = await deriveSharedKey(keyPair.privateKey, walletPublicKey); + + const channel = new MessageChannel(); + this.port = channel.port1; + + this.port.onmessage = async (event: MessageEvent) => { + await this.handleEncryptedResponse(event.data); + }; + + this.port.start(); + + // Send connection request with our public key and transfer port2 to content script + // This is the only public postMessage - it contains our public key (safe to expose) + // and transfers the MessagePort for subsequent private communication + const connectRequest: ConnectRequest = { + type: 'aztec-wallet-connect', + walletId: this.extensionId, + appId: this.appId, + publicKey: exportedPublicKey, + }; + + window.postMessage(jsonStringify(connectRequest), '*', [channel.port2]); + } + + /** + * Handles an encrypted response received from the wallet extension. + * + * Decrypts the response using the shared AES key and resolves or rejects + * the corresponding pending promise based on the response content. + * + * @param encrypted - The encrypted response from the wallet + */ + private async handleEncryptedResponse(encrypted: EncryptedPayload): Promise { + if (!this.sharedKey) { + return; + } + + try { + const response = await decrypt(this.sharedKey, encrypted); + + const { messageId, result, error, walletId: responseWalletId } = response; if (!messageId || !responseWalletId) { return; } - if (wallet.extensionId !== responseWalletId) { + if (this.extensionId !== responseWalletId) { return; } - if (!wallet.inFlight.has(messageId)) { + if (!this.inFlight.has(messageId)) { return; } - const { resolve, reject } = wallet.inFlight.get(messageId)!; + const { resolve, reject } = this.inFlight.get(messageId)!; if (error) { reject(new Error(jsonStringify(error))); } else { resolve(result); } - wallet.inFlight.delete(messageId); - }); - - // Create a Proxy that intercepts wallet method calls and forwards them to the extension - return new Proxy(wallet, { - get: (target, prop) => { - if (schemaHasMethod(WalletSchema, prop.toString())) { - return async (...args: unknown[]) => { - const result = await target.postMessage({ - type: prop.toString() as keyof FunctionsOf, - args, - }); - return WalletSchema[prop.toString() as keyof typeof WalletSchema].returnType().parseAsync(result); - }; - } else { - return target[prop as keyof ExtensionWallet]; - } - }, - }) as unknown as Wallet; + this.inFlight.delete(messageId); + // eslint-disable-next-line no-empty + } catch {} } - private postMessage(call: WalletMethodCall): Promise { + /** + * Sends an encrypted wallet method call over the secure MessageChannel. + * + * The message is encrypted using AES-256-GCM with the shared key derived + * during channel establishment. A unique message ID is generated to correlate + * the response. + * + * @param call - The wallet method call containing method name and arguments + * @returns A Promise that resolves with the decrypted result from the wallet + * + * @throws Error if the secure channel has not been established + */ + private async postMessage(call: WalletMethodCall): Promise { + if (!this.port || !this.sharedKey) { + throw new Error('Secure channel not established'); + } + const messageId = globalThis.crypto.randomUUID(); const message: WalletMessage = { type: call.type, @@ -115,10 +242,34 @@ export class ExtensionWallet { walletId: this.extensionId, }; - window.postMessage(jsonStringify(message), '*'); + // Encrypt the message and send over the private MessageChannel + const encrypted = await encrypt(this.sharedKey, message); + this.port.postMessage(encrypted); const { promise, resolve, reject } = promiseWithResolvers(); this.inFlight.set(messageId, { promise, resolve, reject }); return promise; } + + /** + * Closes the secure channel and cleans up resources. + * + * After calling this method, the wallet instance can no longer be used. + * Any pending requests will not receive responses. + * + * @example + * ```typescript + * const wallet = await ExtensionWallet.create(walletInfo, chainInfo, 'my-app'); + * // ... use wallet ... + * wallet.close(); // Clean up when done + * ``` + */ + close(): void { + if (this.port) { + this.port.close(); + this.port = null; + } + this.sharedKey = null; + this.inFlight.clear(); + } } diff --git a/yarn-project/wallet-sdk/src/providers/extension/index.ts b/yarn-project/wallet-sdk/src/providers/extension/index.ts index 0a4dc66f7c66..fe0e0d60ee06 100644 --- a/yarn-project/wallet-sdk/src/providers/extension/index.ts +++ b/yarn-project/wallet-sdk/src/providers/extension/index.ts @@ -1,3 +1,11 @@ export { ExtensionWallet } from './extension_wallet.js'; export { ExtensionProvider } from './extension_provider.js'; -export type { WalletInfo, WalletMessage, WalletResponse, DiscoveryRequest, DiscoveryResponse } from '../types.js'; +export * from '../../crypto.js'; +export type { + WalletInfo, + WalletMessage, + WalletResponse, + DiscoveryRequest, + DiscoveryResponse, + ConnectRequest, +} from '../../types.js'; diff --git a/yarn-project/wallet-sdk/src/providers/types.ts b/yarn-project/wallet-sdk/src/types.ts similarity index 67% rename from yarn-project/wallet-sdk/src/providers/types.ts rename to yarn-project/wallet-sdk/src/types.ts index 45bbd4aacfe6..866a40180882 100644 --- a/yarn-project/wallet-sdk/src/providers/types.ts +++ b/yarn-project/wallet-sdk/src/types.ts @@ -1,7 +1,9 @@ import type { ChainInfo } from '@aztec/aztec.js/account'; +import type { ExportedPublicKey } from './crypto.js'; + /** - * Information about an installed Aztec wallet wallet + * Information about an installed Aztec wallet */ export interface WalletInfo { /** Unique identifier for the wallet */ @@ -12,10 +14,12 @@ export interface WalletInfo { icon?: string; /** Wallet version */ version: string; + /** Wallet's ECDH public key for secure channel establishment */ + publicKey: ExportedPublicKey; } /** - * Message format for wallet communication + * Message format for wallet communication (internal, before encryption) */ export interface WalletMessage { /** Unique message ID for tracking responses */ @@ -47,7 +51,7 @@ export interface WalletResponse { } /** - * Discovery message for finding installed wallets + * Discovery message for finding installed wallets (public, unencrypted) */ export interface DiscoveryRequest { /** Message type for discovery */ @@ -59,7 +63,7 @@ export interface DiscoveryRequest { } /** - * Discovery response from an wallet + * Discovery response from a wallet (public, unencrypted) */ export interface DiscoveryResponse { /** Message type for discovery response */ @@ -69,3 +73,17 @@ export interface DiscoveryResponse { /** Wallet information */ walletInfo: WalletInfo; } + +/** + * Connection request to establish secure channel + */ +export interface ConnectRequest { + /** Message type for connection */ + type: 'aztec-wallet-connect'; + /** Target wallet ID */ + walletId: string; + /** Application ID */ + appId: string; + /** dApp's ECDH public key for deriving shared secret */ + publicKey: ExportedPublicKey; +}