Harden desktop local relay boundaries

Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
This commit is contained in:
npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh
2026-08-10 12:43:25 -04:00
committed by Brother Darryl
parent ac9f9e84c4
commit 884cd41ce7
7 changed files with 109 additions and 9 deletions
+49 -3
View File
@@ -96,13 +96,17 @@ pub(crate) fn start(
) -> Result<LocalRelayRuntime, String> {
let relay_port = requested_port.unwrap_or(free_loopback_port()?);
let owner_pubkey = keys.public_key().to_hex();
let data_dir = local_data_dir(app, &owner_pubkey)?;
let relay_keys = load_or_create_relay_keys(&data_dir)?;
let config = LocalRelayConfig {
relay_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), relay_port),
health_port: free_loopback_port()?,
metrics_port: free_loopback_port()?,
data_dir: local_data_dir(app, &owner_pubkey)?,
data_dir,
owner_pubkey,
relay_private_key: keys.secret_key().to_secret_hex(),
// Relay-originated events must have a separate service identity. The
// human key authorizes membership only and is never passed to the child.
relay_private_key: relay_keys.secret_key().to_secret_hex(),
};
let url = config.url();
let media_dir = config.data_dir.join("media");
@@ -223,6 +227,26 @@ fn sqlite_url(path: &Path) -> String {
format!("sqlite://{}", path.display())
}
/// Load the relay's service identity from its identity-scoped nest, or create
/// it once with the same atomic, owner-only file semantics used for private
/// desktop identities. This key signs relay-originated metadata; it is never
/// the human/owner key supplied by `apply_workspace`.
fn load_or_create_relay_keys(data_dir: &Path) -> Result<Keys, String> {
let path = data_dir.join("relay-service.key");
if path.exists() {
let value = std::fs::read_to_string(&path)
.map_err(|e| format!("read local relay service key: {e}"))?;
return Keys::parse(value.trim())
.map_err(|e| format!("parse local relay service key: {e}"));
}
std::fs::create_dir_all(data_dir).map_err(|e| format!("create local relay data dir: {e}"))?;
let keys = Keys::generate();
crate::app_state::save_key_file(&path, &keys)
.map_err(|e| format!("persist local relay service key: {e}"))?;
Ok(keys)
}
fn free_loopback_port() -> Result<u16, String> {
let listener =
TcpListener::bind("127.0.0.1:0").map_err(|e| format!("pick local relay port: {e}"))?;
@@ -256,7 +280,8 @@ fn relay_binary() -> Result<PathBuf, String> {
#[cfg(test)]
mod tests {
use super::{
is_local_relay_url, local_relay_url, sqlite_url, LocalRelayConfig, LOCAL_RELAY_SENTINEL,
is_local_relay_url, load_or_create_relay_keys, local_relay_url, sqlite_url,
LocalRelayConfig, LOCAL_RELAY_SENTINEL,
};
use std::{
collections::HashMap,
@@ -281,6 +306,27 @@ mod tests {
assert!(!is_local_relay_url("wss://127.0.0.1:4317"));
}
#[test]
fn relay_service_key_is_durable_and_distinct_from_owner_identity() {
let directory = tempfile::tempdir().expect("temp data dir");
let owner = nostr::Keys::generate();
let first = load_or_create_relay_keys(directory.path()).expect("create service key");
let second = load_or_create_relay_keys(directory.path()).expect("reload service key");
assert_ne!(first.public_key(), owner.public_key());
assert_eq!(first.public_key(), second.public_key());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(directory.path().join("relay-service.key"))
.expect("service key metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
fn local_relay_environment_uses_only_the_single_node_contract() {
let config = LocalRelayConfig {
@@ -1,4 +1,5 @@
import { READ_STATE_MAX_PLAINTEXT_BYTES } from "@/features/channels/readState/readStateFormat";
import { hasCommunityNetworkEndpoint } from "@/features/communities/communityStorage";
import type { Community } from "@/features/communities/types";
import { fetchObservedChannels } from "@/features/communities/communityUnreadObserver";
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
@@ -120,6 +121,9 @@ export async function markCommunityRead(
community: Community,
pubkey: string,
): Promise<void> {
// The sentinel is storage-only. Inactive local communities do not run a
// sidecar, so they cannot receive a background read-state publication.
if (!hasCommunityNetworkEndpoint(community.relayUrl)) return;
await withReadOnlyRelayClient(community.relayUrl, (client) =>
publishCommunityReadState({
client,
@@ -3,12 +3,14 @@ import test from "node:test";
import {
clearCommunityStorage,
deriveCommunityName,
hasCommunityNetworkEndpoint,
initFirstCommunity,
isLocalCommunityRelayUrl,
LOCAL_COMMUNITY_NAME,
LOCAL_COMMUNITY_RELAY_URL,
loadCommunities,
loadCommunityDiscoveryAfterLeave,
LOCAL_COMMUNITY_NAME,
LOCAL_COMMUNITY_RELAY_URL,
markCommunityDiscoveryAfterLeave,
migrateLegacyCommunityStorage,
saveCommunities,
@@ -56,6 +58,18 @@ test("migrateLegacyCommunityStorage does not overwrite new community state", ()
assert.equal(storage.getItem("buzz-active-community-id"), "new");
});
test("only the local sentinel identifies the desktop-managed workspace", () => {
assert.equal(isLocalCommunityRelayUrl(LOCAL_COMMUNITY_RELAY_URL), true);
assert.equal(
deriveCommunityName(LOCAL_COMMUNITY_RELAY_URL),
LOCAL_COMMUNITY_NAME,
);
assert.equal(isLocalCommunityRelayUrl("ws://127.0.0.1:4317"), false);
assert.equal(isLocalCommunityRelayUrl("ws://localhost:4317"), false);
assert.equal(hasCommunityNetworkEndpoint(LOCAL_COMMUNITY_RELAY_URL), false);
assert.equal(hasCommunityNetworkEndpoint("ws://127.0.0.1:4317"), true);
});
test("signed-build relay defaults auto-connect during first-run onboarding", () => {
assert.equal(
shouldAutoConnectDefaultRelay("wss://buzz.block.builderlab.xyz"),
@@ -135,3 +149,17 @@ test("clearCommunityStorage preserves completed final-leave discovery", () => {
assert.equal(storage.length, 1);
assert.equal(loadCommunityDiscoveryAfterLeave(storage), true);
});
test("clearCommunityStorage removes new and legacy state", () => {
const storage = createMemoryStorage({
"buzz-communities": "new",
"buzz-active-community-id": "new",
"buzz-workspaces": "old",
"buzz-active-workspace-id": "old",
});
clearCommunityStorage(storage);
migrateLegacyCommunityStorage(storage);
assert.equal(storage.length, 0);
});
@@ -17,6 +17,11 @@ export function isLocalCommunityRelayUrl(relayUrl: string): boolean {
return relayUrl === LOCAL_COMMUNITY_RELAY_URL;
}
/** Stored local sentinel is never a network endpoint. */
export function hasCommunityNetworkEndpoint(relayUrl: string): boolean {
return !isLocalCommunityRelayUrl(relayUrl);
}
/**
* Expand a leading `~` to the user's home directory. The backend rejects
* `~`-prefixed paths (`std::fs` does not expand the shell tilde), so the UI
@@ -19,6 +19,7 @@ import {
mutedChannelIdsFromStore,
parseMutePayload,
} from "@/features/sidebar/lib/channelMutesStorage";
import { hasCommunityNetworkEndpoint } from "@/features/communities/communityStorage";
import type { Community } from "@/features/communities/types";
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
@@ -148,6 +149,12 @@ export async function pollCommunityUnread(
community: Community,
pubkey: string,
): Promise<CommunityUnreadObserverResult> {
// Local mode owns exactly one active sidecar. An inactive local community has
// no durable network endpoint: its stored sentinel must never reach a relay
// client. The active workspace already receives ordinary unread handling.
if (!hasCommunityNetworkEndpoint(community.relayUrl)) {
return { hasUnread: false, mentionCount: 0 };
}
return withReadOnlyRelayClient(community.relayUrl, (client) =>
fetchCommunityUnread({ client, pubkey }),
);
@@ -5,6 +5,7 @@ import { useMyRelayMembershipLookupQuery } from "@/features/community-members/ho
import type { Community } from "@/features/communities/types";
import {
expandTilde,
isLocalCommunityRelayUrl,
normalizeRelayUrl,
} from "@/features/communities/communityStorage";
import { validateReposDir } from "@/shared/api/tauri";
@@ -82,9 +83,11 @@ export function EditCommunityDialog({
updates.name = trimmedName;
}
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
if (normalizedUrl !== community.relayUrl) {
updates.relayUrl = normalizedUrl;
if (!isLocalCommunityRelayUrl(community.relayUrl)) {
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
if (normalizedUrl !== community.relayUrl) {
updates.relayUrl = normalizedUrl;
}
}
const trimmedToken = token.trim() || undefined;
@@ -170,6 +173,7 @@ export function EditCommunityDialog({
Relay URL
</label>
<Input
disabled={isLocalCommunityRelayUrl(community.relayUrl)}
id="edit-ws-relay-url"
onChange={(e) => setRelayUrl(e.target.value)}
placeholder="wss://relay.example.com"
@@ -2,6 +2,7 @@ import { useQueries, useQuery } from "@tanstack/react-query";
import { fetchCommunityIcon } from "@/shared/api/communityProfile";
import { isLocalCommunityRelayUrl } from "./communityStorage";
import type { Community } from "./types";
import {
loadCachedCommunityIcon,
@@ -22,9 +23,13 @@ async function fetchIconForCommunity(
}
function iconQueryOptions(community: Community) {
const local = isLocalCommunityRelayUrl(community.relayUrl);
return {
queryKey: communityIconQueryKey(community.relayUrl),
queryFn: () => fetchIconForCommunity(community),
// A local sentinel is storage-only and has no HTTP origin. Its active
// relay is resolved privately by desktop; inactive local mode is stopped.
enabled: !local,
// Cached icon renders immediately; the fetch still runs and replaces it.
placeholderData: loadCachedCommunityIcon(community.relayUrl),
staleTime: ICON_STALE_MS,
@@ -55,6 +60,7 @@ export function useCommunityIcons(
/** Icon of the ACTIVE community, for settings preview. */
export function useActiveCommunityIcon(relayUrl: string | undefined) {
const local = relayUrl !== undefined && isLocalCommunityRelayUrl(relayUrl);
return useQuery({
queryKey: communityIconQueryKey(relayUrl ?? ""),
queryFn: async () => {
@@ -62,7 +68,7 @@ export function useActiveCommunityIcon(relayUrl: string | undefined) {
if (relayUrl) saveCachedCommunityIcon(relayUrl, icon);
return icon;
},
enabled: relayUrl !== undefined,
enabled: relayUrl !== undefined && !local,
staleTime: ICON_STALE_MS,
});
}