mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Harden desktop local relay boundaries
Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
This commit is contained in:
parent
ac9f9e84c4
commit
884cd41ce7
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user