Merge branch 'plan/e2ee' - End-to-end encryption for relay connections

Implements E2EE for relay connections only, keeping direct connections
and CLI plaintext. The relay acts as a dumb byte forwarder with no
access to decrypted messages.

Key changes:
- Daemon keypair persistence in $PASEO_HOME/daemon-keypair.json
- Encrypted relay transport using ECDH P-256 + AES-256-GCM
- Client-side E2EE using daemon's public key from pairing offer
- WebCrypto compatibility for React Native
This commit is contained in:
Mohamed Boudra
2026-02-04 15:00:34 +07:00
29 changed files with 1311 additions and 314 deletions
@@ -4,12 +4,13 @@ import {
type HostProfile,
} from "@/contexts/daemon-registry-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
import { useEffect, useMemo, useRef, useState } from "react";
function buildCandidateUrls(daemon: HostProfile): string[] {
const endpoints = daemon.endpoints ?? [];
const sessionId = daemon.relay?.sessionId ?? null;
const relayEndpoint = daemon.relay?.endpoint ?? null;
const lastKnownGood =
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
? (daemon.metadata.lastKnownGoodEndpoint as string)
@@ -20,38 +21,26 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
if (!out.includes(url)) out.push(url);
};
if (lastKnownGood) {
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
const directEndpoints = endpoints;
if (sessionId && relayEndpoint && isLastKnownRelay) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
} else if (lastKnownGood) {
push(buildDaemonWebSocketUrl(lastKnownGood));
}
for (const endpoint of endpoints) {
for (const endpoint of directEndpoints) {
push(buildDaemonWebSocketUrl(endpoint));
}
if (sessionId) {
if (lastKnownGood) {
push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId }));
}
for (const endpoint of endpoints) {
push(buildRelayWebSocketUrl({ endpoint, sessionId }));
}
if (sessionId && relayEndpoint) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
}
return out;
}
function extractEndpointFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const host = parsed.hostname;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
} catch {
return null;
}
}
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
const { connectionStates } = useDaemonConnections();
const { updateDaemon } = useDaemonRegistry();
@@ -86,9 +75,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
useEffect(() => {
if (!connection) return;
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = extractEndpointFromUrl(activeUrl);
if (status === "online") {
if (pendingMetadataWriteRef.current) return;
const endpoint = (() => {
try {
return extractHostPortFromWebSocketUrl(activeUrl);
} catch {
return null;
}
})();
if (!endpoint) return;
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
@@ -123,7 +118,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
]);
return (
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
<SessionProvider
key={`${daemon.id}:${activeUrl}`}
serverUrl={activeUrl}
serverId={daemon.id}
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
>
{null}
</SessionProvider>
);
@@ -2,7 +2,6 @@ import { createContext, useCallback, useContext } from "react";
import type { ReactNode } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import {
buildDaemonWebSocketUrl,
decodeOfferFragmentPayload,
@@ -10,6 +9,10 @@ import {
extractHostPortFromWebSocketUrl,
normalizeHostPort,
} from "@/utils/daemon-endpoints";
import {
ConnectionOfferV1Schema,
type ConnectionOfferV1,
} from "@server/shared/connection-offer";
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -41,15 +44,6 @@ type CreateHostInput = {
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
const ConnectionOfferV1Schema = z.object({
v: z.literal(1),
sessionId: z.string().min(1),
endpoints: z.array(z.string().min(1)).min(1),
daemonPublicKeyB64: z.string().min(1),
});
export type ConnectionOfferV1 = z.infer<typeof ConnectionOfferV1Schema>;
interface DaemonRegistryContextValue {
daemons: HostProfile[];
isLoading: boolean;
@@ -134,7 +128,16 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const existing = readDaemons();
const now = new Date().toISOString();
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
let relayEndpoint: string | null = null;
if (offer.relay?.endpoint) {
relayEndpoint = normalizeHostPort(offer.relay.endpoint);
} else if (offer.relay === undefined && normalizedEndpoints.length > 0) {
// Back-compat: older offers encoded relay endpoint as the last entry.
relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
}
const relay = relayEndpoint
? { endpoint: relayEndpoint, sessionId: offer.sessionId }
: null;
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
if (matchIndex !== -1) {
@@ -142,7 +145,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
...existing[matchIndex],
daemonPublicKeyB64: offer.daemonPublicKeyB64,
endpoints: normalizedEndpoints,
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
relay,
updatedAt: now,
};
const next = [...existing];
@@ -156,7 +159,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
endpoints: normalizedEndpoints,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
relay,
createdAt: now,
updatedAt: now,
metadata: null,
@@ -257,6 +257,7 @@ interface SessionProviderProps {
children: ReactNode;
serverUrl: string;
serverId: string;
daemonPublicKeyB64?: string;
}
// SessionProvider: Daemon client message handler that updates Zustand store
@@ -264,8 +265,9 @@ export function SessionProvider({
children,
serverUrl,
serverId,
daemonPublicKeyB64,
}: SessionProviderProps) {
const client = useDaemonClient(serverUrl);
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
const [connectionSnapshot, setConnectionSnapshot] =
useState<DaemonConnectionSnapshot>(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
+15 -2
View File
@@ -9,7 +9,14 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
});
}
export function useDaemonClient(url: string): DaemonClient {
type DaemonClientOptions = {
daemonPublicKeyB64?: string;
};
export function useDaemonClient(
url: string,
options: DaemonClientOptions = {}
): DaemonClient {
const client = useMemo(
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
@@ -19,9 +26,15 @@ export function useDaemonClient(url: string): DaemonClient {
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
e2ee: options.daemonPublicKeyB64
? {
enabled: true,
daemonPublicKeyB64: options.daemonPublicKeyB64,
}
: undefined,
});
},
[url]
[options.daemonPublicKeyB64, url]
);
useEffect(() => {
+32 -4
View File
@@ -7,16 +7,44 @@ declare global {
}
export function polyfillCrypto(): void {
if (typeof globalThis.crypto === "undefined") {
(globalThis as any).crypto = {};
let webcrypto: Crypto | null = null;
try {
// Prefer the React Native entrypoint to avoid the browser shim.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const rnModule = require("@sphereon/isomorphic-webcrypto/src/react-native");
webcrypto = (rnModule?.default ?? rnModule) as Crypto;
} catch {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fallbackModule = require("@sphereon/isomorphic-webcrypto");
webcrypto = (fallbackModule?.default ?? fallbackModule) as Crypto;
}
if (typeof globalThis.crypto.randomUUID !== "function") {
const existing = (globalThis as any).crypto as Crypto | null | undefined;
let target = existing;
if (!target || typeof (target as Crypto).subtle === "undefined") {
target = (webcrypto && typeof webcrypto === "object" ? (webcrypto as Crypto) : undefined) ?? ({} as Crypto);
(globalThis as any).crypto = target;
}
const ensureSecure = (globalThis as any).crypto?.ensureSecure as
| (() => Promise<void>)
| undefined;
if (typeof ensureSecure === "function") {
void ensureSecure();
}
if (typeof (globalThis as any).crypto?.randomUUID !== "function") {
if (!globalThis.crypto) {
(globalThis as any).crypto = {} as Crypto;
}
globalThis.crypto.randomUUID = () =>
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
}
if (typeof globalThis.crypto.getRandomValues !== "function") {
if (typeof (globalThis as any).crypto?.getRandomValues !== "function") {
if (!globalThis.crypto) {
(globalThis as any).crypto = {} as Crypto;
}
globalThis.crypto.getRandomValues = <T extends ArrayBufferView>(array: T): T => {
return ExpoCrypto.getRandomValues(array as any) as T;
};
+18 -97
View File
@@ -1,9 +1,22 @@
import { Buffer } from "buffer";
import {
buildDaemonWebSocketUrl,
buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl,
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseHostPort,
type HostPortParts,
} from "@server/shared/daemon-endpoints";
export type HostPortParts = {
host: string;
port: number;
isIpv6: boolean;
export type { HostPortParts };
export {
buildDaemonWebSocketUrl,
deriveLabelFromEndpoint,
extractHostPortFromWebSocketUrl,
normalizeHostPort,
parseHostPort,
};
function decodeBase64UrlToUtf8(input: string): string {
@@ -17,98 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
return JSON.parse(json) as unknown;
}
export function parseHostPort(input: string): HostPortParts {
const trimmed = input.trim();
if (!trimmed) {
throw new Error("Host is required");
}
// IPv6: [::1]:6767
if (trimmed.startsWith("[")) {
const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
if (!match) {
throw new Error("Invalid host:port (expected [::1]:6767)");
}
const host = match[1].trim();
const port = Number(match[2]);
if (!host) throw new Error("Host is required");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return { host, port, isIpv6: true };
}
const match = trimmed.match(/^(.+):(\d{1,5})$/);
if (!match) {
throw new Error("Invalid host:port (expected localhost:6767)");
}
const host = match[1].trim();
const port = Number(match[2]);
if (!host) throw new Error("Host is required");
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Port must be between 1 and 65535");
}
return { host, port, isIpv6: false };
}
export function normalizeHostPort(input: string): string {
const { host, port, isIpv6 } = parseHostPort(input);
if (isIpv6) {
return `[${host}]:${port}`;
}
return `${host}:${port}`;
}
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
const parsed = new URL(wsUrl);
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
throw new Error("Invalid WebSocket URL protocol");
}
if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
throw new Error("Invalid WebSocket URL (expected /ws path)");
}
const host = parsed.hostname;
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
if (!host) {
throw new Error("Invalid WebSocket URL (missing hostname)");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Invalid WebSocket URL (invalid port)");
}
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
return hostPort;
}
function shouldUseSecureWebSocket(port: number): boolean {
return port === 443;
}
export function buildDaemonWebSocketUrl(endpoint: string): string {
const { host, port, isIpv6 } = parseHostPort(endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
return `${protocol}://${hostPart}:${port}/ws`;
}
export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string {
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
const hostPart = isIpv6 ? `[${host}]` : host;
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
url.searchParams.set("session", params.sessionId);
url.searchParams.set("role", "client");
return url.toString();
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
}
export function deriveLabelFromEndpoint(endpoint: string): string {
try {
const { host } = parseHostPort(endpoint);
return host || "Unnamed Host";
} catch {
return "Unnamed Host";
}
}