1
0
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:
Katherine Reynolds
2025-11-17 15:51:04 -08:00
parent 5ce1aa78d0
commit 7e02df22e0
5 changed files with 1301 additions and 83 deletions

View 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;
}

View 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}`;
}
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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();
}
}
}