feat: implement end-to-end encryption for relay connections

This commit is contained in:
Mohamed Boudra
2026-02-03 13:40:44 +07:00
parent a7a39ffd01
commit 6ed70e24c7
23 changed files with 1004 additions and 115 deletions
@@ -10,6 +10,7 @@ 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,21 +21,24 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
if (!out.includes(url)) out.push(url);
};
if (lastKnownGood) {
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
const directEndpoints = relayEndpoint
? endpoints.filter((endpoint) => endpoint !== relayEndpoint)
: endpoints;
if (lastKnownGood && !isLastKnownRelay) {
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) {
if (isLastKnownRelay) {
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
}
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
}
return out;
@@ -123,7 +127,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>
);
@@ -326,6 +326,7 @@ interface SessionProviderProps {
children: ReactNode;
serverUrl: string;
serverId: string;
daemonPublicKeyB64?: string;
}
// SessionProvider: Daemon client message handler that updates Zustand store
@@ -333,8 +334,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)
+27 -2
View File
@@ -9,19 +9,44 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
});
}
export function useDaemonClient(url: string): DaemonClientV2 {
type DaemonClientOptions = {
daemonPublicKeyB64?: string;
};
function isRelayWebSocketUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("session");
} catch {
return false;
}
}
export function useDaemonClient(
url: string,
options: DaemonClientOptions = {}
): DaemonClientV2 {
const client = useMemo(
() => {
const tauriTransportFactory = createTauriWebSocketTransportFactory();
const relayConnection = isRelayWebSocketUrl(url);
return new DaemonClientV2({
url,
suppressSendErrors: true,
...(tauriTransportFactory
? { transportFactory: tauriTransportFactory }
: {}),
...(relayConnection
? {
e2ee: {
enabled: true,
daemonPublicKeyB64: options.daemonPublicKeyB64,
},
}
: {}),
});
},
[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;
};