diff --git a/apps/browser/rust_noise_bg.wasm b/apps/browser/rust_noise_bg.wasm new file mode 100644 index 00000000000..9c54bf61f1e Binary files /dev/null and b/apps/browser/rust_noise_bg.wasm differ diff --git a/apps/browser/src/tools/popup/settings/noise-protocol.service.ts b/apps/browser/src/tools/popup/settings/noise-protocol.service.ts new file mode 100644 index 00000000000..a723f2d3a32 --- /dev/null +++ b/apps/browser/src/tools/popup/settings/noise-protocol.service.ts @@ -0,0 +1,236 @@ +import { Injectable } from "@angular/core"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +type KeyPair = { publicKey: Uint8Array; secretKey: Uint8Array }; + +/** + * Noise Protocol WASM adapter for browser extension + * Wraps the Rust/WASM implementation to provide secure Noise_XXpsk3_25519_AESGCM_SHA256 handshakes + */ +@Injectable({ + providedIn: "root", +}) +export class NoiseProtocolService { + private wasmModule: any = null; + private isInitialized = false; + + constructor(private logService: LogService) {} + + /** + * Initialize the WASM module + * Must be called before using any other methods + */ + async initialize(): Promise { + if (this.isInitialized) { + return; + } + + try { + // Import the WASM module - hardcoded path for demo + // The WASM file will be in the build output directory alongside other assets + const wasmModule = await import("./rust_noise.js"); + + // Initialize WASM - for --target web, the default export is the init function + if (typeof wasmModule.default === "function") { + // Pass the WASM file path explicitly to avoid import.meta.url issues in bundled code + await wasmModule.default("./rust_noise_bg.wasm"); + } else if (typeof wasmModule.init === "function") { + // Fallback for other targets + await wasmModule.init(); + } + + this.wasmModule = wasmModule; + this.isInitialized = true; + this.logService.info("[NoiseProtocol] WASM module initialized"); + } catch (error) { + this.logService.error("[NoiseProtocol] Failed to initialize WASM module:", error); + throw new Error(`Failed to initialize Noise Protocol WASM: ${error}`); + } + } + + /** + * Create a new Noise Protocol instance + */ + createProtocol(isInitiator: boolean, staticKeypair?: KeyPair, psk?: Uint8Array): NoiseProtocol { + if (!this.isInitialized || !this.wasmModule) { + throw new Error("NoiseProtocolService not initialized. Call initialize() first."); + } + + return new NoiseProtocol(this.wasmModule, isInitiator, staticKeypair, psk, this.logService); + } + + /** + * Perform full XXpsk3 handshake (3 messages) + */ + async performXXHandshake( + isInitiator: boolean, + sendMessage: (msg: Uint8Array) => Promise, + staticKeypair?: KeyPair, + psk?: Uint8Array, + ): Promise { + await this.initialize(); + + const noise = this.createProtocol(isInitiator, staticKeypair, psk); + + if (isInitiator) { + this.logService.info("[NoiseProtocol] Step 1: Initiator sending message 1 (-> e)"); + const msg1 = noise.writeMessage(); + const msg2 = await sendMessage(msg1); + + this.logService.info( + "[NoiseProtocol] Step 2: Initiator received message 2 (<- e, ee, s, es)", + ); + noise.readMessage(msg2); + + this.logService.info("[NoiseProtocol] Step 3: Initiator sending message 3 (-> s, se, psk)"); + const msg3 = noise.writeMessage(); + await sendMessage(msg3); + + noise.split(); + } else { + this.logService.info("[NoiseProtocol] Responder waiting for messages..."); + // Responder waits for messages, handled separately + } + + return noise; + } +} + +/** + * Noise Protocol wrapper using Rust/WASM implementation + * Implements Noise_XXpsk3_25519_AESGCM_SHA256 pattern + */ +export class NoiseProtocol { + private wasmProtocol: any; + private isInitiator: boolean; + + constructor( + wasmModule: any, + isInitiator: boolean, + staticKeypair?: KeyPair, + psk?: Uint8Array, + private logService?: LogService, + ) { + this.isInitiator = isInitiator; + + try { + // Convert optional parameters + const staticSecretKey = staticKeypair ? staticKeypair.secretKey : null; + const pskBytes = psk ? psk : null; + + // Create WASM protocol instance + this.wasmProtocol = new wasmModule.NoiseProtocol(isInitiator, staticSecretKey, pskBytes); + + this.log( + `Noise XX handshake initialized (${isInitiator ? "initiator" : "responder"}, PSK: ${psk ? "yes" : "no"})`, + ); + } catch (error) { + this.logError("Failed to create WASM NoiseProtocol:", error); + throw error; + } + } + + /** + * Write a handshake message + */ + writeMessage(payload?: Uint8Array): Uint8Array { + try { + const payloadArray = payload && payload.length > 0 ? payload : null; + const message = this.wasmProtocol.writeMessage(payloadArray); + + this.log(`Sent handshake message (length: ${message.length})`); + return message; + } catch (error) { + this.logError("Failed to write message:", error); + throw error; + } + } + + /** + * Read a handshake message + */ + readMessage(message: Uint8Array): Uint8Array { + try { + const payload = this.wasmProtocol.readMessage(message); + + this.log( + `Received handshake message (length: ${message.length}, payload: ${payload.length})`, + ); + return payload; + } catch (error) { + this.logError("Failed to read message:", error); + throw error; + } + } + + /** + * Complete the handshake and derive transport keys + */ + split(): void { + try { + this.wasmProtocol.split(); + this.log("Handshake complete - transport keys derived"); + } catch (error) { + this.logError("Failed to split:", error); + throw error; + } + } + + /** + * Encrypt a message after handshake is complete + */ + encryptMessage(plaintext: Uint8Array): Uint8Array { + try { + const ciphertext = this.wasmProtocol.encryptMessage(plaintext); + + this.log(`Message encrypted (length: ${ciphertext.length})`); + return ciphertext; + } catch (error) { + this.logError("Failed to encrypt:", error); + throw error; + } + } + + /** + * Decrypt a message after handshake is complete + */ + decryptMessage(ciphertext: Uint8Array): Uint8Array { + try { + const plaintext = this.wasmProtocol.decryptMessage(ciphertext); + + this.log(`Message decrypted (length: ${plaintext.length})`); + return plaintext; + } catch (error) { + this.logError("Failed to decrypt:", error); + throw error; + } + } + + /** + * Check if handshake is complete + */ + isHandshakeComplete(): boolean { + return this.wasmProtocol.isHandshakeComplete(); + } + + /** + * Get static public key + * Note: WASM implementation doesn't expose this directly + */ + getStaticPublicKey(): Uint8Array { + throw new Error("getStaticPublicKey not yet implemented for WASM"); + } + + private log(message: string) { + if (this.logService) { + this.logService.info(`[NoiseProtocol] ${message}`); + } + } + + private logError(message: string, error?: any) { + if (this.logService) { + this.logService.error(`[NoiseProtocol] ${message}`, error); + } + } +} diff --git a/apps/browser/src/tools/popup/settings/rust_noise.d.ts b/apps/browser/src/tools/popup/settings/rust_noise.d.ts new file mode 100644 index 00000000000..0173e4e515a --- /dev/null +++ b/apps/browser/src/tools/popup/settings/rust_noise.d.ts @@ -0,0 +1,129 @@ +/* tslint:disable */ + +/** + * Generate a new Curve25519 keypair + */ +export function generate_keypair(): Keypair; +/** + * Initialize the WASM module + */ +export function init(): void; +/** + * Keypair structure + */ +export class Keypair { + free(): void; + [Symbol.dispose](): void; + constructor(public_key: Uint8Array, secret_key: Uint8Array); + readonly public_key: Uint8Array; + readonly secret_key: Uint8Array; +} +/** + * Noise Protocol state machine + */ +export class NoiseProtocol { + free(): void; + [Symbol.dispose](): void; + /** + * Read a handshake message from the peer + * Returns the payload contained in the message + */ + readMessage(message: Uint8Array): Uint8Array; + /** + * Write a handshake message + * Returns the message to send to the peer + */ + writeMessage(payload?: Uint8Array | null): Uint8Array; + /** + * Decrypt a message (after handshake is complete) + */ + decryptMessage(ciphertext: Uint8Array): Uint8Array; + /** + * Encrypt a message (after handshake is complete) + */ + encryptMessage(plaintext: Uint8Array): Uint8Array; + /** + * Check if handshake is complete + */ + isHandshakeComplete(): boolean; + /** + * Get the remote static public key (available after handshake) + */ + getRemoteStaticPublicKey(): Uint8Array; + /** + * Create a new Noise protocol instance + * + * # Arguments + * * `is_initiator` - Whether this is the initiator (true) or responder (false) + * * `static_keypair` - Optional static keypair (if None, generates new one) + * * `psk` - Optional pre-shared key for additional authentication + */ + constructor( + is_initiator: boolean, + static_secret_key?: Uint8Array | null, + psk?: Uint8Array | null, + ); + /** + * Complete the handshake and derive transport keys + */ + split(): void; +} + +export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; + +export interface InitOutput { + readonly memory: WebAssembly.Memory; + readonly __wbg_keypair_free: (a: number, b: number) => void; + readonly __wbg_noiseprotocol_free: (a: number, b: number) => void; + readonly generate_keypair: (a: number) => void; + readonly init: () => void; + readonly keypair_new: (a: number, b: number, c: number, d: number) => number; + readonly keypair_public_key: (a: number, b: number) => void; + readonly keypair_secret_key: (a: number, b: number) => void; + readonly noiseprotocol_decryptMessage: (a: number, b: number, c: number, d: number) => void; + readonly noiseprotocol_encryptMessage: (a: number, b: number, c: number, d: number) => void; + readonly noiseprotocol_getRemoteStaticPublicKey: (a: number, b: number) => void; + readonly noiseprotocol_isHandshakeComplete: (a: number) => number; + readonly noiseprotocol_new: ( + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => void; + readonly noiseprotocol_readMessage: (a: number, b: number, c: number, d: number) => void; + readonly noiseprotocol_split: (a: number, b: number) => void; + readonly noiseprotocol_writeMessage: (a: number, b: number, c: number, d: number) => void; + readonly __wbindgen_export: (a: number) => void; + readonly __wbindgen_add_to_stack_pointer: (a: number) => number; + readonly __wbindgen_export2: (a: number, b: number) => number; + readonly __wbindgen_export3: (a: number, b: number, c: number) => void; + readonly __wbindgen_start: () => void; +} + +export type SyncInitInput = BufferSource | WebAssembly.Module; +/** + * Instantiates the given `module`, which can either be bytes or + * a precompiled `WebAssembly.Module`. + * + * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated. + * + * @returns {InitOutput} + */ +export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput; + +/** + * If `module_or_path` is {RequestInfo} or {URL}, makes a request and + * for everything else, calls `WebAssembly.instantiate` directly. + * + * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated. + * + * @returns {Promise} + */ +export default function __wbg_init( + module_or_path?: + | { module_or_path: InitInput | Promise } + | InitInput + | Promise, +): Promise; 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 e194bf6e2a4..5f07084f900 100644 --- a/apps/browser/src/tools/popup/settings/tunnel-demo.component.html +++ b/apps/browser/src/tools/popup/settings/tunnel-demo.component.html @@ -16,6 +16,18 @@ Enter your username to connect to the remote tunnel. + + + Use Noise Protocol (XXpsk3) Encryption + Enable end-to-end encryption using the Noise Protocol Framework with WASM. + 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 216cd0277e1..afc0be95c0d 100644 --- a/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts +++ b/apps/browser/src/tools/popup/settings/tunnel-demo.component.ts @@ -50,6 +50,7 @@ import { TunnelService } from "./tunnel.service"; export class TunnelDemoComponent { protected formGroup = this.formBuilder.group({ tunnelUsername: ["", Validators.required], + useNoiseProtocol: [false], }); constructor( @@ -125,17 +126,30 @@ export class TunnelDemoComponent { } // Send credentials to the localhost tunnel server - try { - await this.tunnelService.sendCredentials({ tunnelUsername, username, password }); + const useNoiseProtocol = this.formGroup.value.useNoiseProtocol ?? false; - 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, - }); + try { + if (useNoiseProtocol) { + await this.tunnelService.sendCredentialsWithNoise({ tunnelUsername, username, password }); + + 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) { await this.dialogService.openSimpleDialog({ title: "Tunnel Demo - Error", diff --git a/apps/browser/src/tools/popup/settings/tunnel.service.ts b/apps/browser/src/tools/popup/settings/tunnel.service.ts index 1b01b0288fc..8584b529351 100644 --- a/apps/browser/src/tools/popup/settings/tunnel.service.ts +++ b/apps/browser/src/tools/popup/settings/tunnel.service.ts @@ -2,6 +2,8 @@ import { Injectable } from "@angular/core"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { NoiseProtocolService } from "./noise-protocol.service"; + export interface TunnelCredentials { tunnelUsername: string; username: string; @@ -11,6 +13,7 @@ export interface TunnelCredentials { /** * Service for securely sending credentials to a localhost tunnel server. * SECURITY: Only allows connections to loopback addresses (localhost/127.0.0.1/[::1]). + * Uses Noise Protocol (XXpsk3) for end-to-end encryption. */ @Injectable({ providedIn: "root", @@ -20,7 +23,10 @@ export class TunnelService { private readonly TUNNEL_ENDPOINT = "/bwTunnelDemo"; private readonly ALLOWED_LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"]; - constructor(private logService: LogService) {} + constructor( + private logService: LogService, + private noiseProtocolService: NoiseProtocolService, + ) {} /** * Sends credentials to the local tunnel server. @@ -65,6 +71,87 @@ export class TunnelService { } } + /** + * Sends credentials to the local tunnel server with Noise Protocol encryption. + * Performs a full XXpsk3 handshake and sends encrypted credentials. + * @param credentials The username and password to send + * @throws Error if the connection is not to a loopback address or if the request fails + */ + async sendCredentialsWithNoise(credentials: TunnelCredentials): Promise { + const url = `http://localhost:${this.TUNNEL_PORT}${this.TUNNEL_ENDPOINT}`; + + // Validate that we're only connecting to localhost + const urlObj = new URL(url); + if (!this.isLoopbackAddress(urlObj.hostname)) { + const error = `Security violation: Attempted to connect to non-loopback address: ${urlObj.hostname}`; + this.logService.error(error); + throw new Error("Tunnel service only allows connections to localhost"); + } + + try { + // Initialize Noise Protocol WASM module + await this.noiseProtocolService.initialize(); + + // Perform Noise handshake as initiator + const noise = await this.noiseProtocolService.performXXHandshake( + true, // isInitiator + async (message: Uint8Array) => { + // Send handshake message to server + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "X-Noise-Handshake": "true", + }, + body: message, + mode: "cors", + credentials: "omit", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error( + `Tunnel server responded with status ${response.status}: ${response.statusText}`, + ); + } + + // Return the response message + const arrayBuffer = await response.arrayBuffer(); + return new Uint8Array(arrayBuffer); + }, + ); + + // Encrypt credentials + const credentialsJson = JSON.stringify(credentials); + const credentialsBytes = new TextEncoder().encode(credentialsJson); + const encryptedCredentials = noise.encryptMessage(credentialsBytes); + + // Send encrypted credentials + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "X-Noise-Transport": "true", + }, + body: encryptedCredentials, + mode: "cors", + credentials: "omit", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error( + `Tunnel server responded with status ${response.status}: ${response.statusText}`, + ); + } + + this.logService.info("Encrypted credentials successfully sent to tunnel server"); + } catch (error) { + this.logService.error(`Failed to send encrypted credentials to tunnel server: ${error}`); + throw error; + } + } + /** * Validates that a hostname is a loopback address. * @param hostname The hostname to validate