diff --git a/apps/browser/src/tools/popup/settings/crypto-utils.ts b/apps/browser/src/tools/popup/settings/crypto-utils.ts new file mode 100644 index 00000000000..1555e3c3215 --- /dev/null +++ b/apps/browser/src/tools/popup/settings/crypto-utils.ts @@ -0,0 +1,166 @@ +/** + * Crypto utilities for PSK authentication using HKDF + * Based on the Noise Protocol pairing implementation + */ + +/** + * Generate a random salt for PSK derivation + * @returns 32-byte random salt + */ +export function generatePSKSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(32)); +} + +/** + * Derive a Pre-Shared Key (PSK) from password using HKDF with SHA-256 + * + * @param password - The pairing password + * @param salt - Random salt (32 bytes) to prevent rainbow table attacks + * @returns 32-byte PSK for Noise protocol + */ +export async function derivePSKFromPassword( + password: string, + salt: Uint8Array, +): Promise { + // Import password as key material + const passwordBytes = new TextEncoder().encode(password); + const keyMaterial = await crypto.subtle.importKey("raw", passwordBytes, { name: "HKDF" }, false, [ + "deriveBits", + ]); + + // Info string for HKDF + const info = new TextEncoder().encode("noise-pairing-psk-v1"); + + // Derive 32-byte key using HKDF + const derivedBits = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: salt, + info: info, + }, + keyMaterial, + 256, // 32 bytes * 8 bits + ); + + return new Uint8Array(derivedBits); +} + +/** + * Convert Uint8Array to base64 string (browser-compatible) + */ +export function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ""; + const len = bytes.length; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary); +} + +/** + * Convert base64 string to Uint8Array (browser-compatible) + */ +export function base64ToUint8Array(base64: string): Uint8Array { + if (!base64 || typeof base64 !== "string") { + throw new Error("Invalid base64 input: must be a non-empty string"); + } + + 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; +} + +/** + * Encode a pairing code that bundles password, salt, and username together + * + * Format: password:base64(JSON{salt, username}) + * Example: "K7X9:eyJzIjoiNG44d0YyLi4uIiwidSI6ImFuZGVycyJ9" + * + * @param password - The pairing password + * @param salt - The PSK salt + * @param username - The username to encode in the pairing code + * @returns Combined pairing code string + */ +export function encodePairingCode(password: string, salt: Uint8Array, username: string): string { + const saltBase64 = uint8ArrayToBase64(salt); + const metadata = { + s: saltBase64, + u: username, + }; + const metadataJson = JSON.stringify(metadata); + const metadataBase64 = btoa(metadataJson); + + return `${password}:${metadataBase64}`; +} + +/** + * Decode a pairing code into password, salt, and username components + * + * @param pairingCode - The combined pairing code + * @returns Object containing password, salt, and username + * @throws Error if pairing code format is invalid + */ +export function decodePairingCode(pairingCode: string): { + password: string; + salt: Uint8Array; + username: string; +} { + const parts = pairingCode.split(":"); + + if (parts.length !== 2) { + throw new Error("Invalid pairing code format. Expected format: password:metadata"); + } + + const [password, metadataBase64] = parts; + + if (!password || password.length === 0) { + throw new Error("Invalid pairing code: password is empty"); + } + + let metadata: { s: string; u: string }; + try { + const metadataJson = atob(metadataBase64); + metadata = JSON.parse(metadataJson); + } catch { + throw new Error("Invalid pairing code: metadata is not valid base64 JSON"); + } + + if (!metadata.s || !metadata.u) { + throw new Error("Invalid pairing code: missing salt or username in metadata"); + } + + let salt: Uint8Array; + try { + salt = base64ToUint8Array(metadata.s); + } catch { + throw new Error("Invalid pairing code: salt is not valid base64"); + } + + if (salt.length !== 32) { + throw new Error(`Invalid pairing code: salt must be 32 bytes, got ${salt.length}`); + } + + return { password, salt, username: metadata.u }; +} + +/** + * Generate a random pairing password (e.g., 4-character code) + * @param length - Length of the password (default: 4) + * @returns Random alphanumeric password + */ +export function generatePairingPassword(length: number = 4): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Exclude ambiguous characters + let password = ""; + const randomValues = crypto.getRandomValues(new Uint8Array(length)); + + for (let i = 0; i < length; i++) { + password += chars[randomValues[i] % chars.length]; + } + + return password; +} diff --git a/apps/browser/src/tools/popup/settings/keypair-storage.service.ts b/apps/browser/src/tools/popup/settings/keypair-storage.service.ts new file mode 100644 index 00000000000..af0f61217c6 --- /dev/null +++ b/apps/browser/src/tools/popup/settings/keypair-storage.service.ts @@ -0,0 +1,254 @@ +import { Injectable } from "@angular/core"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { base64ToUint8Array, uint8ArrayToBase64 } from "./crypto-utils"; + +export interface KeyPair { + publicKey: Uint8Array; + secretKey: Uint8Array; +} + +interface StoredKeypair { + publicKey: string; // base64 + secretKey: string; // base64 + createdAt: number; +} + +/** + * Browser-based Static Keypair Management Service + * + * Manages persistent Noise Protocol static keypairs for devices using browser storage. + * Each device maintains its own static keypair for mutual authentication. + */ +@Injectable({ + providedIn: "root", +}) +export class KeypairStorageService { + private readonly STORAGE_PREFIX = "noise-static-key-"; + + constructor(private logService: LogService) {} + + /** + * Generate a new Noise Protocol static keypair using X25519 (via WebCrypto) + * @returns KeyPair with public and secret keys + */ + async generateKeypair(): Promise { + try { + // Generate X25519 keypair using WebCrypto API + const keyPair = await crypto.subtle.generateKey( + { + name: "X25519", + namedCurve: "X25519", + } as any, // X25519 is not fully typed yet + true, + ["deriveBits"], + ); + + // Export the keys to raw format + const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); + const secretKeyRaw = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + + // For X25519, we need to extract the raw 32-byte secret key from PKCS#8 + // PKCS#8 format has headers, the actual key is the last 32 bytes + const secretKeyArray = new Uint8Array(secretKeyRaw); + const secretKey = secretKeyArray.slice(-32); + + return { + publicKey: new Uint8Array(publicKeyRaw), + secretKey: secretKey, + }; + } catch { + // Fallback: generate random bytes if X25519 is not supported + this.logService.warning( + "[KeypairStorage] X25519 not supported in WebCrypto, using random fallback", + ); + + const secretKey = crypto.getRandomValues(new Uint8Array(32)); + + // Simple scalar multiplication for public key (this is a simplified fallback) + // In production, you'd want proper X25519 implementation + const publicKey = await this.derivePublicKey(secretKey); + + return { + publicKey, + secretKey, + }; + } + } + + /** + * Derive public key from secret key (simplified fallback) + * Note: This is a placeholder. The WASM module should handle proper X25519. + */ + private async derivePublicKey(secretKey: Uint8Array): Promise { + // This is a simplified version - the WASM Noise implementation + // will use proper X25519 scalar multiplication + const hash = await crypto.subtle.digest("SHA-256", secretKey); + return new Uint8Array(hash).slice(0, 32); + } + + /** + * Generate and persist a new static keypair for a device + * + * @param deviceId - Unique identifier for the device + * @returns KeyPair that was generated and saved + */ + async generateStaticKeypair(deviceId: string): Promise { + const keypair = await this.generateKeypair(); + const storageKey = this.getStorageKey(deviceId); + + const stored: StoredKeypair = { + publicKey: uint8ArrayToBase64(keypair.publicKey), + secretKey: uint8ArrayToBase64(keypair.secretKey), + createdAt: Date.now(), + }; + + localStorage.setItem(storageKey, JSON.stringify(stored)); + + this.logService.info(`[KeypairStorage] Generated static keypair for device: ${deviceId}`); + return keypair; + } + + /** + * Load an existing static keypair for a device + * + * @param deviceId - Unique identifier for the device + * @returns KeyPair if found, null if not found + */ + loadStaticKeypair(deviceId: string): KeyPair | null { + const storageKey = this.getStorageKey(deviceId); + const stored = localStorage.getItem(storageKey); + + if (!stored) { + return null; + } + + try { + const parsed: StoredKeypair = JSON.parse(stored); + + return { + publicKey: base64ToUint8Array(parsed.publicKey), + secretKey: base64ToUint8Array(parsed.secretKey), + }; + } catch (error) { + this.logService.error( + `[KeypairStorage] Failed to load keypair for device ${deviceId}:`, + error, + ); + // Clear corrupted data + this.logService.info( + `[KeypairStorage] Clearing corrupted keypair data for device ${deviceId}`, + ); + localStorage.removeItem(storageKey); + return null; + } + } + + /** + * Get or create a static keypair for a device + * + * Loads existing keypair if available, otherwise generates a new one. + * + * @param deviceId - Unique identifier for the device + * @returns KeyPair for the device + */ + async getOrCreateStaticKeypair(deviceId: string): Promise { + const existing = this.loadStaticKeypair(deviceId); + + if (existing) { + this.logService.info( + `[KeypairStorage] Loaded existing static keypair for device: ${deviceId}`, + ); + return existing; + } + + this.logService.info( + `[KeypairStorage] No existing keypair found for device: ${deviceId}, generating new one`, + ); + return this.generateStaticKeypair(deviceId); + } + + /** + * Check if a static keypair exists for a device + * + * @param deviceId - Unique identifier for the device + * @returns true if keypair exists, false otherwise + */ + hasStaticKeypair(deviceId: string): boolean { + const storageKey = this.getStorageKey(deviceId); + return localStorage.getItem(storageKey) !== null; + } + + /** + * Delete a static keypair for a device + * + * @param deviceId - Unique identifier for the device + * @returns true if deleted, false if not found + */ + deleteStaticKeypair(deviceId: string): boolean { + const storageKey = this.getStorageKey(deviceId); + + if (localStorage.getItem(storageKey) === null) { + return false; + } + + try { + localStorage.removeItem(storageKey); + this.logService.info(`[KeypairStorage] Deleted static keypair for device: ${deviceId}`); + return true; + } catch (error) { + this.logService.error( + `[KeypairStorage] Failed to delete keypair for device ${deviceId}:`, + error, + ); + return false; + } + } + + /** + * List all devices with stored static keypairs + * + * @returns Array of device IDs + */ + listDevices(): string[] { + const devices: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(this.STORAGE_PREFIX)) { + const deviceId = key.substring(this.STORAGE_PREFIX.length); + devices.push(deviceId); + } + } + + return devices; + } + + /** + * Delete all static keypairs + * + * WARNING: This will remove all device authentication keys. + * Devices will need to re-pair after this operation. + */ + clearAllKeypairs(): void { + const devices = this.listDevices(); + + for (const deviceId of devices) { + this.deleteStaticKeypair(deviceId); + } + + this.logService.info("[KeypairStorage] Cleared all static keypairs"); + } + + /** + * Get the localStorage key for a device's static keypair + * @param deviceId - Unique identifier for the device + * @returns localStorage key + */ + private getStorageKey(deviceId: string): string { + // Sanitize deviceId to prevent issues + const sanitized = deviceId.replace(/[^a-zA-Z0-9-_]/g, "_"); + return `${this.STORAGE_PREFIX}${sanitized}`; + } +} diff --git a/apps/browser/src/tools/popup/settings/tunnel-client.service.ts b/apps/browser/src/tools/popup/settings/tunnel-client.service.ts new file mode 100644 index 00000000000..f7c5b85f8b2 --- /dev/null +++ b/apps/browser/src/tools/popup/settings/tunnel-client.service.ts @@ -0,0 +1,476 @@ +import { Injectable } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { + base64ToUint8Array, + derivePSKFromPassword, + encodePairingCode, + generatePairingPassword, + generatePSKSalt, + uint8ArrayToBase64, +} from "./crypto-utils"; +import { KeypairStorageService, type KeyPair } from "./keypair-storage.service"; +import { NoiseProtocol, NoiseProtocolService } from "./noise-protocol.service"; + +/** + * Event types emitted by the tunnel client + */ +export type TunnelClientEvent = + | { type: "listening"; username: string } + | { type: "pairing_code_generated"; pairingCode: string; password: string } + | { + type: "connection-request"; + clientId: string; + remoteUsername: string; + respond: (approved: boolean) => void; + } + | { type: "connection-approved"; clientId: string; remoteUsername: string } + | { type: "connection-denied"; clientId: string; remoteUsername: string } + | { type: "auth-complete"; remoteUsername: string; phase: "cached" | "first-time" } + | { type: "handshake-start"; remoteUsername: string } + | { type: "handshake-progress"; remoteUsername: string; message: string } + | { type: "handshake-complete"; remoteUsername: string } + | { + type: "credential-request"; + domain: string; + remoteUsername: string; + respond: (approved: boolean, credential?: any) => void; + } + | { type: "credential-approved"; domain: string; remoteUsername: string } + | { type: "credential-denied"; domain: string; remoteUsername: string } + | { type: "error"; error: Error; context: string } + | { type: "disconnected" }; + +/** + * Configuration for tunnel client + */ +export interface TunnelClientConfig { + proxyUrl: string; + username: string; + password?: string; // Optional - will be generated if not provided +} + +/** + * Tunnel Client Service - Angular port of WebUserClient + * Manages secure tunnel connections using Noise Protocol + */ +@Injectable({ + providedIn: "root", +}) +export class TunnelClientService { + private ws?: WebSocket; + private noiseProtocol?: NoiseProtocol; + private pskSalt?: Uint8Array; + private psk?: Uint8Array; + private staticKeypair?: KeyPair; + private config?: TunnelClientConfig; + + private eventsSubject = new BehaviorSubject(null); + events$: Observable = this.eventsSubject.asObservable(); + + private isConnected = false; + + constructor( + private logService: LogService, + private noiseProtocolService: NoiseProtocolService, + private keypairStorage: KeypairStorageService, + ) {} + + /** + * Start listening for tunnel connections + * @param config Configuration for the tunnel client + */ + async listen(config: TunnelClientConfig): Promise { + this.config = config; + + try { + // Step 1: Connect to proxy + this.emitEvent({ type: "listening", username: config.username }); + this.logService.info(`[TunnelClient] Connecting to proxy: ${config.proxyUrl}`); + + await this.connectToProxy(config); + this.logService.info("[TunnelClient] Connected to proxy and listening"); + + // Step 2: Load or create static keypair for this device + this.staticKeypair = await this.keypairStorage.getOrCreateStaticKeypair( + `user-client-${config.username}`, + ); + this.logService.info("[TunnelClient] Static keypair loaded/created"); + + // Step 3: Generate pairing code (password + PSK salt + username) + const password = config.password || generatePairingPassword(4); + this.pskSalt = generatePSKSalt(); + this.psk = await derivePSKFromPassword(password, this.pskSalt); + const pairingCode = encodePairingCode(password, this.pskSalt, config.username); + + this.logService.info("[TunnelClient] Pairing code generated"); + this.emitEvent({ type: "pairing_code_generated", pairingCode, password }); + + // Step 4: Set up message handlers + this.setupMessageHandlers(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.emitEvent({ type: "error", error: err, context: "listen" }); + throw error; + } + } + + /** + * Close connection + */ + close(): void { + if (this.ws) { + this.ws.close(); + this.ws = undefined; + this.isConnected = false; + this.emitEvent({ type: "disconnected" }); + } + } + + /** + * Check if ready to handle credentials + */ + isReady(): boolean { + return !!(this.noiseProtocol && this.noiseProtocol.isHandshakeComplete()); + } + + /** + * Connect to proxy server using WebSocket + */ + private async connectToProxy(config: TunnelClientConfig): Promise { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(config.proxyUrl); + + this.ws.onopen = () => { + this.ws!.send( + JSON.stringify({ + type: "user-client-connect", + username: config.username, + sessionId: `user-session-${Date.now()}`, + }), + ); + }; + + this.ws.onmessage = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data); + + if (message.type === "connect-response") { + if (message.success) { + this.logService.info("[TunnelClient] Connected to proxy"); + this.isConnected = true; + resolve(); + } else { + reject(new Error(`Proxy connection failed: ${message.error}`)); + } + } + } catch { + // Not JSON or not connect response + } + }; + + this.ws.onerror = (error) => { + reject(new Error("WebSocket connection error")); + }; + + this.ws.onclose = () => { + this.logService.info("[TunnelClient] WebSocket connection closed"); + this.isConnected = false; + this.emitEvent({ type: "disconnected" }); + }; + }); + } + + /** + * Set up WebSocket message handlers + */ + private setupMessageHandlers(): void { + if (!this.ws) { + throw new Error("Not connected to proxy"); + } + + this.ws.onmessage = async (event: MessageEvent) => { + try { + // Handle both text and binary WebSocket messages + let messageText: string; + if (typeof event.data === "string") { + messageText = event.data; + } else if (event.data instanceof Blob) { + messageText = await event.data.text(); + } else if (event.data instanceof ArrayBuffer) { + const decoder = new TextDecoder(); + messageText = decoder.decode(event.data); + } else { + this.logService.warning("[TunnelClient] Unknown WebSocket message type"); + return; + } + + const message = JSON.parse(messageText); + + // Route to appropriate handler + switch (message.type) { + case "connection-request": + await this.handleConnectionRequest(message); + break; + case "cached-auth": + await this.handleCachedAuth(message); + break; + case "first-time-auth": + await this.handleFirstTimeAuth(message); + break; + case "noise-message-1": + await this.handleNoiseMessage1(message); + break; + case "noise-message-3": + await this.handleNoiseMessage3(message); + break; + case "credential-request": + await this.handleCredentialRequest(message); + break; + } + } catch (error) { + this.logService.error("[TunnelClient] Error handling WebSocket message:", error); + } + }; + } + + /** + * Handle connection request from remote client + */ + private async handleConnectionRequest(message: any): Promise { + const { clientId, username, sessionId } = message; + + // For now, always request approval from user + // In future, implement approval storage + const event: TunnelClientEvent = { + type: "connection-request", + clientId, + remoteUsername: username, + respond: (approved: boolean) => { + if (approved) { + this.emitEvent({ + type: "connection-approved", + clientId, + remoteUsername: username, + }); + } else { + this.emitEvent({ + type: "connection-denied", + clientId, + remoteUsername: username, + }); + } + + this.ws!.send( + JSON.stringify({ + type: "connection-approval", + sessionId, + approved, + }), + ); + + this.logService.info(`[TunnelClient] Connection approval sent: ${approved}`); + }, + }; + + this.emitEvent(event); + } + + /** + * Handle cached authentication notification + */ + private async handleCachedAuth(message: any): Promise { + const { username, clientId } = message; + + // Use the existing PSK + this.logService.info(`[TunnelClient] Using cached PSK for ${username}, client id ${clientId}`); + + this.emitEvent({ + type: "auth-complete", + remoteUsername: username, + phase: "cached", + }); + } + + /** + * Handle first-time authentication + */ + private async handleFirstTimeAuth(message: any): Promise { + const { username, clientId } = message; + + if (!this.psk) { + this.logService.error("[TunnelClient] PSK not generated"); + return; + } + + this.logService.info(`[TunnelClient] First-time auth for ${username}, ${clientId}`); + + this.emitEvent({ + type: "auth-complete", + remoteUsername: username, + phase: "first-time", + }); + } + + /** + * Handle Noise message 1 (handshake start) + */ + private async handleNoiseMessage1(message: any): Promise { + if (!this.psk) { + this.logService.error("[TunnelClient] Cannot start Noise handshake: no PSK"); + return; + } + + if (!this.staticKeypair) { + this.logService.error("[TunnelClient] Cannot start Noise handshake: no static keypair"); + return; + } + + const remoteUsername = message.username || "unknown"; + + this.emitEvent({ + type: "handshake-start", + remoteUsername, + }); + + // Initialize Noise Protocol as responder + await this.noiseProtocolService.initialize(); + this.noiseProtocol = this.noiseProtocolService.createProtocol( + false, + this.staticKeypair, + this.psk, + ); + + const message1 = base64ToUint8Array(message.data); + this.noiseProtocol.readMessage(message1); + + const message2 = this.noiseProtocol.writeMessage(); + this.ws!.send( + JSON.stringify({ + type: "noise-message-2", + data: uint8ArrayToBase64(message2), + }), + ); + + this.emitEvent({ + type: "handshake-progress", + remoteUsername, + message: "Sent message 2", + }); + + this.logService.info("[TunnelClient] Noise message 2 sent"); + } + + /** + * Handle Noise message 3 (handshake complete) + */ + private async handleNoiseMessage3(message: any): Promise { + if (!this.noiseProtocol) { + this.logService.error("[TunnelClient] Noise protocol not initialized"); + return; + } + + const remoteUsername = message.username || "unknown"; + + const message3 = base64ToUint8Array(message.data); + this.noiseProtocol.readMessage(message3); + + this.noiseProtocol.split(); + + this.emitEvent({ + type: "handshake-complete", + remoteUsername, + }); + + this.logService.info("[TunnelClient] Noise handshake complete"); + } + + /** + * Handle credential request from remote client + */ + private async handleCredentialRequest(message: any): Promise { + if (!this.noiseProtocol || !this.noiseProtocol.isHandshakeComplete()) { + this.logService.error("[TunnelClient] Secure channel not established"); + return; + } + + // Decrypt request + const encryptedRequest = base64ToUint8Array(message.encrypted); + const decrypted = this.noiseProtocol.decryptMessage(encryptedRequest); + const requestText = new TextDecoder().decode(decrypted); + const requestData = JSON.parse(requestText); + + const { domain, username: remoteUsername, requestId } = requestData; + + // Create event with respond callback + const event: TunnelClientEvent = { + type: "credential-request", + domain, + remoteUsername, + respond: (approved: boolean, credential?: any) => { + if (approved && credential) { + this.emitEvent({ + type: "credential-approved", + domain, + remoteUsername, + }); + + // Send encrypted credential + const response = JSON.stringify({ + credential, + domain, + timestamp: Date.now(), + requestId, + }); + + const responseBuf = new TextEncoder().encode(response); + const encrypted = this.noiseProtocol!.encryptMessage(responseBuf); + + this.ws!.send( + JSON.stringify({ + type: "credential-response", + encrypted: uint8ArrayToBase64(encrypted), + }), + ); + + this.logService.info(`[TunnelClient] Credential sent for ${domain}`); + } else { + this.emitEvent({ + type: "credential-denied", + domain, + remoteUsername, + }); + + // Send denial + const response = JSON.stringify({ + error: "Credential request denied", + requestId, + }); + + const responseBuf = new TextEncoder().encode(response); + const encrypted = this.noiseProtocol!.encryptMessage(responseBuf); + + this.ws!.send( + JSON.stringify({ + type: "credential-response", + encrypted: uint8ArrayToBase64(encrypted), + }), + ); + + this.logService.info(`[TunnelClient] Credential denied for ${domain}`); + } + }, + }; + + this.emitEvent(event); + } + + /** + * Emit an event to subscribers + */ + private emitEvent(event: TunnelClientEvent): void { + this.eventsSubject.next(event); + } +} diff --git a/apps/browser/src/tools/popup/settings/tunnel-demo.component.html b/apps/browser/src/tools/popup/settings/tunnel-demo.component.html index 5f07084f900..aa1e167035e 100644 --- a/apps/browser/src/tools/popup/settings/tunnel-demo.component.html +++ b/apps/browser/src/tools/popup/settings/tunnel-demo.component.html @@ -1,37 +1,146 @@ - + - + + -

{{ "retrieveCredentials" | i18n }}

+

Pair with Remote Device

-
+ - {{ "tunnelUsername" | i18n }} - - Enter your username to connect to the remote tunnel. - - - - Use Noise Protocol (XXpsk3) Encryption + Proxy URL + Enable end-to-end encryption using the Noise Protocol Framework with WASM.WebSocket URL of the tunnel proxy server (default: ws://localhost:8080) + + + Username + + Your username for this pairing session + + +

+ A pairing code will be generated automatically when you connect. +

+ + + + +

Connection Status

+
+ +
+
+ Status: + + {{ connectionStatus }} + +
+ +
+ Username: + {{ formGroup.value.username }} +
+ + +
+

Pairing Code

+
+ {{ pairingPassword }} +
+

+ Share this code with remote devices to establish secure connection +

+ +

Full Pairing Code:

+
+ {{ pairingCode }} +
+ +
+ + +
+

Activity Log

+
+
+ No activity yet... +
+
+
+ + {{ entry.message }} + + + {{ entry.timestamp | date: "HH:mm:ss" }} + +
+
+
+
+ + +
+
+
+ + + + +

About This Demo

+
+ +

+ This demo implements a secure tunnel using the Noise Protocol Framework with: +

+
    +
  • Noise_XXpsk3_25519_AESGCM_SHA256 pattern
  • +
  • X25519 for key exchange (via WASM)
  • +
  • AES-GCM for encryption
  • +
  • Pre-shared key authentication with HKDF
  • +
  • WebSocket transport for real-time communication
  • +
  • Biometric verification before sending credentials
  • +
+
+
diff --git a/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts b/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts index afc0be95c0d..cd6bbf23883 100644 --- a/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts +++ b/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; @@ -24,7 +24,7 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component"; import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component"; -import { TunnelService } from "./tunnel.service"; +import { TunnelClientService, type TunnelClientEvent } from "./tunnel-client.service"; // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @@ -47,60 +47,263 @@ import { TunnelService } from "./tunnel.service"; JslibModule, ], }) -export class TunnelDemoComponent { +export class TunnelDemoComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + protected formGroup = this.formBuilder.group({ - tunnelUsername: ["", Validators.required], - useNoiseProtocol: [false], + proxyUrl: ["ws://localhost:8080", Validators.required], + username: ["", Validators.required], }); + protected isConnected = false; + protected isListening = false; + protected pairingCode = ""; + protected pairingPassword = ""; + protected connectionStatus = "Not connected"; + protected activityLog: Array<{ timestamp: Date; message: string; type: string }> = []; + + // Track pending approval callbacks + private pendingConnectionApproval?: { + clientId: string; + remoteUsername: string; + respond: (approved: boolean) => void; + }; + + private pendingCredentialRequest?: { + domain: string; + remoteUsername: string; + respond: (approved: boolean, credential?: any) => void; + }; + constructor( private dialogService: DialogService, private cipherService: CipherService, private accountService: AccountService, private formBuilder: FormBuilder, - private tunnelService: TunnelService, + private tunnelClient: TunnelClientService, ) {} - async submit() { + ngOnInit(): void { + // Subscribe to tunnel client events + this.tunnelClient.events$ + .pipe(takeUntil(this.destroy$)) + .subscribe((event) => this.handleTunnelEvent(event)); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.tunnelClient.close(); + } + + /** + * Start pairing - connect to proxy and generate pairing code + */ + async startPairing(): Promise { if (this.formGroup.invalid) { return; } - const tunnelUsername = this.formGroup.value.tunnelUsername?.trim(); - const vaultItemName = "Bitwarden Tunnel Demo"; + const proxyUrl = this.formGroup.value.proxyUrl?.trim(); + const username = this.formGroup.value.username?.trim(); - if (!tunnelUsername) { - await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo", - content: "No tunnel username provided.", - type: "warning", - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - }); + if (!proxyUrl || !username) { return; } - const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); - const allCiphers = await this.cipherService.getAllDecrypted(userId); + try { + this.connectionStatus = "Connecting..."; + this.isListening = true; + this.activityLog = []; + this.addActivityLog("Connecting to proxy server...", "info"); - // Find the cipher with the user-provided name - const tunnelDemoCipher = allCiphers.find( - (cipher: CipherView) => cipher.name === vaultItemName && cipher.type === CipherType.Login, - ); + await this.tunnelClient.listen({ + proxyUrl, + username, + }); + } catch (error) { + this.isListening = false; + this.connectionStatus = "Connection failed"; + this.addActivityLog(`Connection failed: ${error.message || error}`, "error"); - if (!tunnelDemoCipher || !tunnelDemoCipher.login) { await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo", - content: `No vault entry found with the name "${vaultItemName}". Please create one with a username and password.`, - type: "warning", + title: "Connection Error", + content: `Failed to connect to proxy: ${error.message || error}`, + type: "danger", acceptButtonText: { key: "ok" }, cancelButtonText: null, }); + } + } + + /** + * Disconnect from proxy + */ + disconnect(): void { + this.tunnelClient.close(); + this.isConnected = false; + this.isListening = false; + this.connectionStatus = "Disconnected"; + this.pairingCode = ""; + this.addActivityLog("Disconnected from proxy", "info"); + } + + /** + * Copy pairing code to clipboard + */ + async copyPairingCode(): Promise { + try { + await navigator.clipboard.writeText(this.pairingCode); + await this.dialogService.openSimpleDialog({ + title: "Copied", + content: "Pairing code copied to clipboard", + type: "success", + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + }); + } catch { + await this.dialogService.openSimpleDialog({ + title: "Copy Failed", + content: "Failed to copy pairing code to clipboard", + type: "danger", + acceptButtonText: { key: "ok" }, + cancelButtonText: null, + }); + } + } + + /** + * Handle tunnel client events + */ + private async handleTunnelEvent(event: TunnelClientEvent | null): Promise { + if (!event) { return; } - const username = tunnelDemoCipher.login.username || "(none)"; - const password = tunnelDemoCipher.login.password || "(none)"; + switch (event.type) { + case "listening": + this.isConnected = true; + this.connectionStatus = `Connected as ${event.username}`; + this.addActivityLog(`Connected to proxy as ${event.username}`, "success"); + break; + + case "pairing_code_generated": + this.pairingCode = event.pairingCode; + this.pairingPassword = event.password; + this.addActivityLog(`Pairing code generated: ${event.password}`, "success"); + break; + + case "connection-request": + this.pendingConnectionApproval = { + clientId: event.clientId, + remoteUsername: event.remoteUsername, + respond: event.respond, + }; + this.addActivityLog( + `Connection request from ${event.remoteUsername} (${event.clientId})`, + "warning", + ); + await this.showConnectionApprovalDialog(); + break; + + case "connection-approved": + this.addActivityLog( + `Connection approved for ${event.remoteUsername} (${event.clientId})`, + "success", + ); + break; + + case "connection-denied": + this.addActivityLog( + `Connection denied for ${event.remoteUsername} (${event.clientId})`, + "warning", + ); + break; + + case "auth-complete": + this.addActivityLog( + `Authentication complete with ${event.remoteUsername} (${event.phase})`, + "success", + ); + break; + + case "handshake-start": + this.addActivityLog(`Starting Noise handshake with ${event.remoteUsername}`, "info"); + break; + + case "handshake-progress": + this.addActivityLog(`Handshake: ${event.message}`, "info"); + break; + + case "handshake-complete": + this.addActivityLog(`Secure channel established with ${event.remoteUsername}`, "success"); + break; + + case "credential-request": + this.pendingCredentialRequest = { + domain: event.domain, + remoteUsername: event.remoteUsername, + respond: event.respond, + }; + this.addActivityLog( + `Credential request for ${event.domain} from ${event.remoteUsername}`, + "warning", + ); + await this.showCredentialRequestDialog(); + break; + + case "credential-approved": + this.addActivityLog(`Credential sent for ${event.domain}`, "success"); + break; + + case "credential-denied": + this.addActivityLog(`Credential request denied for ${event.domain}`, "warning"); + break; + + case "error": + this.addActivityLog(`Error (${event.context}): ${event.error.message}`, "error"); + break; + + case "disconnected": + this.isConnected = false; + this.isListening = false; + this.connectionStatus = "Disconnected"; + this.addActivityLog("Disconnected from proxy", "info"); + break; + } + } + + /** + * Show dialog to approve/deny connection request + */ + private async showConnectionApprovalDialog(): Promise { + if (!this.pendingConnectionApproval) { + return; + } + + const { remoteUsername, clientId, respond } = this.pendingConnectionApproval; + + const result = await this.dialogService.openSimpleDialog({ + title: "Connection Request", + content: `Remote device requesting connection:\n\nUsername: ${remoteUsername}\nClient ID: ${clientId}\n\nDo you want to approve this connection?`, + type: "warning", + acceptButtonText: { key: "approve" }, + cancelButtonText: { key: "deny" }, + }); + + respond(result); + this.pendingConnectionApproval = undefined; + } + + /** + * Show dialog to approve/deny credential request + */ + private async showCredentialRequestDialog(): Promise { + if (!this.pendingCredentialRequest) { + return; + } + + const { domain, remoteUsername, respond } = this.pendingCredentialRequest; // Verify user identity before sending credentials const verificationResult = await UserVerificationDialogComponent.open(this.dialogService, { @@ -108,56 +311,66 @@ export class TunnelDemoComponent { title: "verificationRequired", bodyText: "verifyIdentityToSendCredentials", calloutOptions: { - text: "credentialsWillBeSentToTunnel", + text: `Credential request for ${domain} from ${remoteUsername}`, type: "warning", }, }); // Check if user cancelled or verification failed if (verificationResult.userAction === "cancel" || !verificationResult.verificationSuccess) { - await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo - Cancelled", - content: "User verification was cancelled or failed.", - type: "info", - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - }); + respond(false); + this.pendingCredentialRequest = undefined; return; } - // Send credentials to the localhost tunnel server - const useNoiseProtocol = this.formGroup.value.useNoiseProtocol ?? false; + // Look up credential from vault + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const allCiphers = await this.cipherService.getAllDecrypted(userId); - try { - if (useNoiseProtocol) { - await this.tunnelService.sendCredentialsWithNoise({ tunnelUsername, username, password }); + // Find matching cipher for domain + const matchingCipher = allCiphers.find( + (cipher: CipherView) => + cipher.type === CipherType.Login && + cipher.login?.uris?.some((uri) => uri.uri?.includes(domain)), + ); - await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo - Success (Noise Protocol)", - content: `Encrypted credentials successfully sent to tunnel server using Noise Protocol (XXpsk3).\n\nUsername: ${username}\nPassword: ${password.replace(/./g, "*")}`, - type: "success", - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - }); - } else { - await this.tunnelService.sendCredentials({ tunnelUsername, username, password }); - - await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo - Success", - content: `Credentials successfully sent to tunnel server.\n\nUsername: ${username}\nPassword: ${password.replace(/./g, "*")}`, - type: "success", - acceptButtonText: { key: "ok" }, - cancelButtonText: null, - }); - } - } catch (error) { + if (!matchingCipher || !matchingCipher.login) { await this.dialogService.openSimpleDialog({ - title: "Tunnel Demo - Error", - content: `Failed to send credentials to tunnel server: ${error.message || error}`, - type: "danger", + title: "No Credential Found", + content: `No credential found for domain: ${domain}`, + type: "warning", acceptButtonText: { key: "ok" }, cancelButtonText: null, }); + respond(false); + this.pendingCredentialRequest = undefined; + return; + } + + // Send credential + const credential = { + username: matchingCipher.login.username || "", + password: matchingCipher.login.password || "", + domain, + }; + + respond(true, credential); + this.pendingCredentialRequest = undefined; + } + + /** + * Add entry to activity log + */ + private addActivityLog(message: string, type: string): void { + this.activityLog.push({ + timestamp: new Date(), + message, + type, + }); + + // Keep only last 50 entries + if (this.activityLog.length > 50) { + this.activityLog.shift(); } } }