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