mirror of
https://github.com/bitwarden/browser
synced 2026-02-02 17:53:41 +00:00
Connection to proxy server (kind of) working!
This commit is contained in:
166
apps/browser/src/tools/popup/settings/crypto-utils.ts
Normal file
166
apps/browser/src/tools/popup/settings/crypto-utils.ts
Normal file
@@ -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<Uint8Array> {
|
||||
// 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;
|
||||
}
|
||||
254
apps/browser/src/tools/popup/settings/keypair-storage.service.ts
Normal file
254
apps/browser/src/tools/popup/settings/keypair-storage.service.ts
Normal file
@@ -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<KeyPair> {
|
||||
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<Uint8Array> {
|
||||
// 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<KeyPair> {
|
||||
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<KeyPair> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
476
apps/browser/src/tools/popup/settings/tunnel-client.service.ts
Normal file
476
apps/browser/src/tools/popup/settings/tunnel-client.service.ts
Normal file
@@ -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<TunnelClientEvent | null>(null);
|
||||
events$: Observable<TunnelClientEvent | null> = 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,146 @@
|
||||
<popup-page>
|
||||
<popup-header slot="header" pageTitle="Tunnel Demo" showBackButton>
|
||||
<popup-header slot="header" pageTitle="Tunnel Demo - Noise Protocol" showBackButton>
|
||||
<ng-container slot="end">
|
||||
<app-pop-out></app-pop-out>
|
||||
</ng-container>
|
||||
</popup-header>
|
||||
|
||||
<bit-section>
|
||||
<!-- Connection Form -->
|
||||
<bit-section *ngIf="!isListening">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">{{ "retrieveCredentials" | i18n }}</h2>
|
||||
<h2 bitTypography="h6">Pair with Remote Device</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<form [formGroup]="formGroup" (ngSubmit)="submit()">
|
||||
<form [formGroup]="formGroup" (ngSubmit)="startPairing()">
|
||||
<bit-form-field>
|
||||
<bit-label>{{ "tunnelUsername" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="tunnelUsername" />
|
||||
<bit-hint>Enter your username to connect to the remote tunnel.</bit-hint>
|
||||
</bit-form-field>
|
||||
<bit-form-field>
|
||||
<input
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
formControlName="useNoiseProtocol"
|
||||
id="useNoiseProtocol"
|
||||
/>
|
||||
<bit-label for="useNoiseProtocol">Use Noise Protocol (XXpsk3) Encryption</bit-label>
|
||||
<bit-label>Proxy URL</bit-label>
|
||||
<input bitInput type="text" formControlName="proxyUrl" />
|
||||
<bit-hint
|
||||
>Enable end-to-end encryption using the Noise Protocol Framework with WASM.</bit-hint
|
||||
>WebSocket URL of the tunnel proxy server (default: ws://localhost:8080)</bit-hint
|
||||
>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Username</bit-label>
|
||||
<input bitInput type="text" formControlName="username" />
|
||||
<bit-hint>Your username for this pairing session</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<button bitButton buttonType="primary" type="submit" [disabled]="formGroup.invalid">
|
||||
{{ "demoRetrieve" | i18n }}
|
||||
Start Pairing
|
||||
</button>
|
||||
<p class="tw-text-sm tw-text-muted tw-mt-2">
|
||||
A pairing code will be generated automatically when you connect.
|
||||
</p>
|
||||
</form>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
|
||||
<!-- Status Display -->
|
||||
<bit-section *ngIf="isListening">
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">Connection Status</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<div class="tw-space-y-4">
|
||||
<div class="tw-flex tw-justify-between tw-items-center">
|
||||
<span class="tw-font-semibold">Status:</span>
|
||||
<span
|
||||
[ngClass]="{
|
||||
'tw-text-success': isConnected,
|
||||
'tw-text-warning': !isConnected && isListening,
|
||||
'tw-text-muted': !isListening,
|
||||
}"
|
||||
>
|
||||
{{ connectionStatus }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-justify-between tw-items-center">
|
||||
<span class="tw-font-semibold">Username:</span>
|
||||
<span>{{ formGroup.value.username }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pairing Code Section -->
|
||||
<div
|
||||
*ngIf="pairingCode"
|
||||
class="tw-border tw-border-solid tw-border-secondary-300 tw-rounded tw-p-4 tw-bg-background-alt"
|
||||
>
|
||||
<h3 bitTypography="h6" class="tw-mb-2">Pairing Code</h3>
|
||||
<div
|
||||
class="tw-font-mono tw-text-lg tw-break-all tw-bg-background tw-p-3 tw-rounded tw-mb-2"
|
||||
>
|
||||
{{ pairingPassword }}
|
||||
</div>
|
||||
<p class="tw-text-sm tw-text-muted tw-mb-3">
|
||||
Share this code with remote devices to establish secure connection
|
||||
</p>
|
||||
|
||||
<h4 bitTypography="body1" class="tw-font-semibold tw-mb-2">Full Pairing Code:</h4>
|
||||
<div
|
||||
class="tw-font-mono tw-text-xs tw-break-all tw-bg-background tw-p-3 tw-rounded tw-mb-2"
|
||||
>
|
||||
{{ pairingCode }}
|
||||
</div>
|
||||
<button bitButton buttonType="secondary" (click)="copyPairingCode()">
|
||||
Copy Pairing Code
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Activity Log -->
|
||||
<div class="tw-mt-4">
|
||||
<h3 bitTypography="h6" class="tw-mb-2">Activity Log</h3>
|
||||
<div
|
||||
class="tw-max-h-64 tw-overflow-y-auto tw-border tw-border-solid tw-border-secondary-300 tw-rounded tw-p-3 tw-bg-background-alt"
|
||||
>
|
||||
<div *ngIf="activityLog.length === 0" class="tw-text-sm tw-text-muted">
|
||||
No activity yet...
|
||||
</div>
|
||||
<div
|
||||
*ngFor="let entry of activityLog"
|
||||
class="tw-text-sm tw-mb-2 tw-pb-2 tw-border-b tw-border-secondary-100"
|
||||
>
|
||||
<div class="tw-flex tw-justify-between tw-items-start">
|
||||
<span
|
||||
[ngClass]="{
|
||||
'tw-text-success': entry.type === 'success',
|
||||
'tw-text-warning': entry.type === 'warning',
|
||||
'tw-text-danger': entry.type === 'error',
|
||||
'tw-text-muted': entry.type === 'info',
|
||||
}"
|
||||
>
|
||||
{{ entry.message }}
|
||||
</span>
|
||||
<span class="tw-text-xs tw-text-muted tw-ml-2 tw-whitespace-nowrap">
|
||||
{{ entry.timestamp | date: "HH:mm:ss" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button bitButton buttonType="secondary" (click)="disconnect()">Disconnect</button>
|
||||
</div>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
|
||||
<!-- Info Section -->
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
<h2 bitTypography="h6">About This Demo</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<p class="tw-text-sm tw-text-muted tw-mb-2">
|
||||
This demo implements a secure tunnel using the Noise Protocol Framework with:
|
||||
</p>
|
||||
<ul class="tw-list-disc tw-list-inside tw-text-sm tw-text-muted tw-space-y-1">
|
||||
<li>Noise_XXpsk3_25519_AESGCM_SHA256 pattern</li>
|
||||
<li>X25519 for key exchange (via WASM)</li>
|
||||
<li>AES-GCM for encryption</li>
|
||||
<li>Pre-shared key authentication with HKDF</li>
|
||||
<li>WebSocket transport for real-time communication</li>
|
||||
<li>Biometric verification before sending credentials</li>
|
||||
</ul>
|
||||
</bit-card>
|
||||
</bit-section>
|
||||
</popup-page>
|
||||
|
||||
@@ -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<void>();
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user