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:
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user