From 454719ff5bcf8b3e197a3cff83795edf1c4c98d5 Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Thu, 23 Jul 2026 12:21:10 -0700 Subject: [PATCH] feat(mobile): identify clients to Buzz servers Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- Cargo.lock | 14 +- crates/buzz-relay/Cargo.toml | 1 + crates/buzz-relay/src/client_info.rs | 238 +++++++++++++++ crates/buzz-relay/src/connection.rs | 30 +- crates/buzz-relay/src/lib.rs | 2 + crates/buzz-relay/src/router.rs | 27 +- .../invites/invite_join_provider.dart | 6 + .../features/pairing/pairing_provider.dart | 11 + .../lib/features/pairing/pairing_socket.dart | 19 +- .../lib/features/settings/settings_page.dart | 12 +- mobile/lib/main.dart | 11 +- mobile/lib/shared/client/client_headers.dart | 275 ++++++++++++++++++ mobile/lib/shared/relay/media_auth.dart | 62 ++-- mobile/lib/shared/relay/media_upload.dart | 12 + mobile/lib/shared/relay/relay.dart | 1 + mobile/lib/shared/relay/relay_session.dart | 30 +- mobile/lib/shared/relay/relay_socket.dart | 9 +- mobile/pubspec.lock | 24 ++ mobile/pubspec.yaml | 1 + .../invites/invite_join_provider_test.dart | 16 + .../pairing/pairing_provider_test.dart | 18 +- .../features/pairing/pairing_socket_test.dart | 35 ++- .../shared/client/client_headers_test.dart | 164 +++++++++++ .../test/shared/relay/media_image_test.dart | 46 ++- .../test/shared/relay/media_upload_test.dart | 8 + .../test/shared/relay/relay_session_test.dart | 24 +- mobile/test/widget_test.dart | 8 + 27 files changed, 1053 insertions(+), 51 deletions(-) create mode 100644 crates/buzz-relay/src/client_info.rs create mode 100644 mobile/lib/shared/client/client_headers.dart create mode 100644 mobile/test/shared/client/client_headers_test.dart diff --git a/Cargo.lock b/Cargo.lock index b5b2605a4b..18ba137dba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1164,6 +1164,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sfv", "sha2 0.11.0", "sqlx", "subtle", @@ -2169,7 +2170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -7965,6 +7966,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sfv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "890ef0e5532daf21fdaa3dbaf991417187f3a87ab69231fe69be942495746480" +dependencies = [ + "base64", + "indexmap", + "ref-cast", +] + [[package]] name = "sha1" version = "0.10.6" diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 0aba81ec84..cb6a6964c6 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -78,6 +78,7 @@ metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } metrics-util = { workspace = true } pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] } +sfv = "0.15.0" [features] dev = ["buzz-auth/dev"] diff --git a/crates/buzz-relay/src/client_info.rs b/crates/buzz-relay/src/client_info.rs new file mode 100644 index 0000000000..653147b445 --- /dev/null +++ b/crates/buzz-relay/src/client_info.rs @@ -0,0 +1,238 @@ +//! Advisory parsing for the mobile `Buzz-Client` structured field. + +use std::convert::Infallible; + +use axum::{ + extract::OptionalFromRequestParts, + http::{request::Parts, HeaderMap}, +}; +use sfv::{BareItem, Dictionary, ListEntry, Parser}; + +/// Parsed, untrusted metadata supplied by a Buzz client. +/// +/// This data is for observability only. It must never participate in +/// authentication, authorization, or tenant selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClientInfo { + /// Structured header format version. + pub format_version: i64, + /// Logical application identifier. + pub app: String, + /// Client platform (`ios` or `android`). + pub platform: String, + /// User-visible application version. + pub app_version: String, + /// Platform build identifier, constrained to decimal digits. + pub app_build: String, + /// Coarse public operating-system version. + pub os_version: String, + /// Android API level, present only on Android. + pub os_api: Option, +} + +impl ClientInfo { + /// Parse `Buzz-Client` from a request header map. + /// + /// A missing header is a supported state and returns `None` without a + /// metric. A present but invalid header increments the parse-failure + /// counter and also returns `None`, so it can never reject a request. + #[must_use] + pub fn from_headers(headers: &HeaderMap) -> Option { + let value = headers.get("buzz-client")?; + let parsed = value.to_str().ok().and_then(|raw| Self::parse(raw).ok()); + if parsed.is_none() { + metrics::counter!("buzz_client_header_parse_failures_total").increment(1); + } + parsed + } + + fn parse(raw: &str) -> Result { + let dictionary: Dictionary = Parser::new(raw).parse_dictionary().map_err(|_| ())?; + + let format_version = integer(&dictionary, "v")?; + if format_version != 1 { + return Err(()); + } + + let app = token(&dictionary, "app")?; + if app != "buzz-mobile" { + return Err(()); + } + + let platform = token(&dictionary, "platform")?; + if platform != "ios" && platform != "android" { + return Err(()); + } + + let app_version = string(&dictionary, "app-version")?; + let app_build = string(&dictionary, "app-build")?; + let os_version = string(&dictionary, "os-version")?; + if app_version.is_empty() + || app_build.is_empty() + || !app_build.bytes().all(|byte| byte.is_ascii_digit()) + || os_version.is_empty() + { + return Err(()); + } + + let os_api = optional_integer(&dictionary, "os-api")?; + match platform.as_str() { + "android" if !matches!(os_api, Some(api) if api > 0) => return Err(()), + "ios" if os_api.is_some() => return Err(()), + _ => {} + } + + Ok(Self { + format_version, + app, + platform, + app_version, + app_build, + os_version, + os_api, + }) + } + + /// Record a low-cardinality observation for a parsed client. + pub fn record_observation(&self) { + metrics::counter!( + "buzz_client_requests_total", + "app" => self.app.clone(), + "platform" => self.platform.clone(), + "app_version" => self.app_version.clone(), + ) + .increment(1); + } +} + +impl OptionalFromRequestParts for ClientInfo +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts( + parts: &mut Parts, + _state: &S, + ) -> Result, Self::Rejection> { + Ok(Self::from_headers(&parts.headers)) + } +} + +fn bare_item<'a>(dictionary: &'a Dictionary, key: &str) -> Result<&'a BareItem, ()> { + let Some(ListEntry::Item(item)) = dictionary.get(key) else { + return Err(()); + }; + if !item.params.is_empty() { + return Err(()); + } + Ok(&item.bare_item) +} + +fn token(dictionary: &Dictionary, key: &str) -> Result { + bare_item(dictionary, key)? + .as_token() + .map(|value| value.as_str().to_owned()) + .ok_or(()) +} + +fn string(dictionary: &Dictionary, key: &str) -> Result { + bare_item(dictionary, key)? + .as_string() + .map(|value| value.as_str().to_owned()) + .ok_or(()) +} + +fn integer(dictionary: &Dictionary, key: &str) -> Result { + bare_item(dictionary, key)? + .as_integer() + .map(Into::into) + .ok_or(()) +} + +fn optional_integer(dictionary: &Dictionary, key: &str) -> Result, ()> { + if !dictionary.contains_key(key) { + return Ok(None); + } + integer(dictionary, key).map(Some) +} + +#[cfg(test)] +mod tests { + use axum::http::{HeaderMap, HeaderValue}; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + + use super::*; + + fn parse_failures(recorder: &DebuggingRecorder) -> u64 { + recorder + .snapshotter() + .snapshot() + .into_vec() + .into_iter() + .find_map(|(key, _, _, value)| { + (key.key().name() == "buzz_client_header_parse_failures_total").then_some(value) + }) + .map(|value| match value { + DebugValue::Counter(value) => value, + _ => panic!("parse failures must be a counter"), + }) + .unwrap_or_default() + } + + #[test] + fn parses_valid_ios_and_android_headers() { + let ios = ClientInfo::parse( + r#"v=1, app=buzz-mobile, platform=ios, app-version="0.4.5", app-build="6", os-version="18.5""#, + ) + .expect("valid iOS header"); + assert_eq!( + ios, + ClientInfo { + format_version: 1, + app: "buzz-mobile".to_owned(), + platform: "ios".to_owned(), + app_version: "0.4.5".to_owned(), + app_build: "6".to_owned(), + os_version: "18.5".to_owned(), + os_api: None, + } + ); + + let android = ClientInfo::parse( + r#"v=1, app=buzz-mobile, platform=android, app-version="0.4.5", app-build="7", os-version="15", os-api=35, future-key=ignored"#, + ) + .expect("valid Android header"); + assert_eq!(android.os_api, Some(35)); + } + + #[test] + fn missing_header_is_absent_without_parse_failure() { + let recorder = DebuggingRecorder::new(); + metrics::with_local_recorder(&recorder, || { + assert_eq!(ClientInfo::from_headers(&HeaderMap::new()), None); + }); + assert_eq!(parse_failures(&recorder), 0); + } + + #[test] + fn malformed_or_semantically_invalid_header_is_absent_and_counted() { + for raw in [ + "not a dictionary", + r#"v=2, app=buzz-mobile, platform=ios, app-version="1", app-build="1", os-version="18""#, + r#"v=1, app=buzz-mobile, platform=android, app-version="1", app-build="1", os-version="15""#, + r#"v=1, app=buzz-mobile, platform=ios, app-version="1", app-build="1.beta", os-version="18""#, + ] { + let recorder = DebuggingRecorder::new(); + let mut headers = HeaderMap::new(); + headers.insert( + "buzz-client", + HeaderValue::from_str(raw).expect("test header value"), + ); + metrics::with_local_recorder(&recorder, || { + assert_eq!(ClientInfo::from_headers(&headers), None, "{raw}"); + }); + assert_eq!(parse_failures(&recorder), 1, "{raw}"); + } + } +} diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 96e266779f..9cbf038488 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -18,6 +18,7 @@ use buzz_auth::{generate_challenge, AuthContext, LimitType}; use buzz_core::tenant::TenantContext; use nostr::Filter; +use crate::client_info::ClientInfo; use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; use crate::state::{run_registered_community_connection, AppState}; @@ -120,6 +121,7 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, + client_info: Option, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -133,7 +135,17 @@ pub async fn handle_connection( community_id, cancel.clone(), move || async move { check_state.db.is_community_active(community_id).await }, - move || handle_active_connection(socket, run_state, addr, tenant, conn_id, cancel), + move || { + handle_active_connection( + socket, + run_state, + addr, + tenant, + conn_id, + cancel, + client_info, + ) + }, ) .await; } @@ -145,6 +157,7 @@ async fn handle_active_connection( tenant: TenantContext, conn_id: Uuid, cancel: CancellationToken, + client_info: Option, ) { let permit = match state.conn_semaphore.clone().try_acquire_owned() { Ok(p) => p, @@ -179,7 +192,20 @@ async fn handle_active_connection( grace_limit: state.config.slow_client_grace_limit, }); - info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); + if let Some(client) = &client_info { + client.record_observation(); + } + info!( + conn_id = %conn_id, + addr = %addr, + client.app = client_info.as_ref().map(|client| client.app.as_str()), + client.platform = client_info.as_ref().map(|client| client.platform.as_str()), + client.app_version = client_info.as_ref().map(|client| client.app_version.as_str()), + client.app_build = client_info.as_ref().map(|client| client.app_build.as_str()), + client.os_version = client_info.as_ref().map(|client| client.os_version.as_str()), + client.os_api = client_info.as_ref().and_then(|client| client.os_api), + "WebSocket connection established" + ); metrics::counter!( "buzz_ws_connections_total", "community" => conn.tenant.host().to_owned() diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e..6356627bdf 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -8,6 +8,8 @@ mod admission; pub mod api; /// WebSocket audio relay for huddle voice channels. pub mod audio; +/// Advisory Buzz client-identification parsing. +pub mod client_info; /// Relay configuration from environment variables. pub mod config; /// Runtime conformance harness — abstract trace emission at the diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 2375a4f9c6..d5253e2fe1 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -17,9 +17,11 @@ use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; +use tracing::Instrument as _; use crate::api; use crate::audio; +use crate::client_info::ClientInfo; use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; @@ -298,10 +300,29 @@ async fn nip11_or_ws_handler( }; let max_frame_bytes = state.config.max_frame_bytes; + let client_info = ClientInfo::from_headers(&headers); match WebSocketUpgrade::from_request(req, &state).await { - Ok(ws) => limit_relay_websocket(ws, max_frame_bytes) - .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) - .into_response(), + Ok(ws) => { + let connection_span = tracing::info_span!( + "ws.connection", + client.app = client_info.as_ref().map(|client| client.app.as_str()), + client.platform = client_info.as_ref().map(|client| client.platform.as_str()), + client.app_version = client_info + .as_ref() + .map(|client| client.app_version.as_str()), + client.app_build = client_info.as_ref().map(|client| client.app_build.as_str()), + client.os_version = client_info + .as_ref() + .map(|client| client.os_version.as_str()), + client.os_api = client_info.as_ref().and_then(|client| client.os_api), + ); + limit_relay_websocket(ws, max_frame_bytes) + .on_upgrade(move |socket| { + handle_connection(socket, state, addr, tenant, client_info) + .instrument(connection_span) + }) + .into_response() + } Err(_) => { // Browser requesting HTML and Git web GUI is enabled → serve SPA. if state.config.serve_git_web_gui { diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 0cd58d47a7..040371f956 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; +import '../../shared/client/client_headers.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/relay/relay_session.dart'; @@ -127,6 +128,11 @@ class InviteJoinNotifier extends Notifier { .post( Uri.parse(url), headers: { + ...clientHeadersForUrl( + headers: ref.read(clientHeadersProvider), + targetUrl: url, + relayUrl: invite.relayUrl, + ), 'Authorization': buildNip98AuthHeader( method: 'POST', url: url, diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 6a6c57a673..f346980658 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -61,6 +61,7 @@ typedef PairingSocketFactory = PairingSocket Function({ required String wsUrl, required String ephemeralPrivkey, + required Map headers, required void Function(List message) onMessage, required void Function(Object? error) onDisconnected, }); @@ -76,11 +77,13 @@ class PairingNotifier extends Notifier { static PairingSocket _createPairingSocket({ required String wsUrl, required String ephemeralPrivkey, + required Map headers, required void Function(List message) onMessage, required void Function(Object? error) onDisconnected, }) => PairingSocket( wsUrl: wsUrl, ephemeralPrivkey: ephemeralPrivkey, + headers: headers, onMessage: onMessage, onDisconnected: onDisconnected, ); @@ -198,6 +201,10 @@ class PairingNotifier extends Notifier { final socket = _socketFactory( wsUrl: relayWsUrl, ephemeralPrivkey: _ephemeralPrivkey!, + headers: clientHeadersForUrl( + headers: ref.read(clientHeadersProvider), + targetUrl: relayWsUrl, + ), onMessage: _handleRelayMessage, onDisconnected: _handleDisconnected, ); @@ -611,6 +618,10 @@ class PairingNotifier extends Notifier { final socket = RelaySocket( wsUrl: wsUrl, nsec: nsec, + headers: clientHeadersForUrl( + headers: ref.read(clientHeadersProvider), + targetUrl: wsUrl, + ), onMessage: (_) {}, onConnected: () {}, onDisconnected: (_) {}, diff --git a/mobile/lib/features/pairing/pairing_socket.dart b/mobile/lib/features/pairing/pairing_socket.dart index 73999dd02d..0f3748d857 100644 --- a/mobile/lib/features/pairing/pairing_socket.dart +++ b/mobile/lib/features/pairing/pairing_socket.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:nostr/nostr.dart' as nostr; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../../shared/relay/nostr_models.dart'; @@ -9,6 +10,9 @@ import '../../shared/relay/nostr_models.dart'; const _desktopPairingAuthChallengeGrace = Duration(seconds: 3); const _pairingAuthOkTimeout = Duration(seconds: 8); +typedef PairingChannelFactory = + WebSocketChannel Function(Uri uri, Map headers); + /// Ephemeral WebSocket connection for NIP-AB pairing. /// /// Uses ephemeral keys for NIP-42 auth (not the stored user keys). @@ -29,7 +33,8 @@ class PairingSocket { final void Function(Object? error) _onDisconnected; final Duration _authChallengeTimeout; final Duration _authResponseTimeout; - final WebSocketChannel Function(Uri uri) _channelFactory; + final Map _headers; + final PairingChannelFactory _channelFactory; WebSocketChannel? _channel; StreamSubscription? _subscription; @@ -46,21 +51,27 @@ class PairingSocket { required void Function(Object? error) onDisconnected, Duration authChallengeTimeout = _desktopPairingAuthChallengeGrace, Duration authResponseTimeout = _pairingAuthOkTimeout, - WebSocketChannel Function(Uri uri) channelFactory = - WebSocketChannel.connect, + Map headers = const {}, + PairingChannelFactory channelFactory = _defaultChannelFactory, }) : _wsUrl = wsUrl, _ephemeralPrivkey = ephemeralPrivkey, _onMessage = onMessage, _onDisconnected = onDisconnected, _authChallengeTimeout = authChallengeTimeout, _authResponseTimeout = authResponseTimeout, + _headers = Map.unmodifiable(headers), _channelFactory = channelFactory; + static WebSocketChannel _defaultChannelFactory( + Uri uri, + Map headers, + ) => IOWebSocketChannel.connect(uri, headers: headers); + bool get isConnected => _connected; /// Connect and answer a NIP-42 challenge when the relay requires one. Future connect() async { - _channel = _channelFactory(Uri.parse(_wsUrl)); + _channel = _channelFactory(Uri.parse(_wsUrl), _headers); await _channel!.ready; _authCompleter = Completer(); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 214360f9eb..d64e794842 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; -import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; @@ -18,7 +16,7 @@ import '../custom_emoji/custom_emoji_provider.dart'; import '../custom_emoji/custom_emoji_render.dart'; import 'theme_picker_page.dart'; -class SettingsPage extends HookConsumerWidget { +class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override @@ -27,8 +25,8 @@ class SettingsPage extends HookConsumerWidget { final selectedAccent = ref.watch(accentProvider); final selectedScheme = ref.watch(schemeProvider); final colorScheme = context.colors; - final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform()); - final packageInfo = useFuture(packageInfoFuture); + final clientHeaders = ref.watch(clientHeadersProvider); + final appVersion = clientHeaders.appVersion; return FrostedScaffold( appBar: const FrostedAppBar(title: Text('Settings')), @@ -166,14 +164,14 @@ class SettingsPage extends HookConsumerWidget { ], ), ), - if (packageInfo.hasData) + if (appVersion.isNotEmpty) SafeArea( top: false, child: Padding( padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs), child: Center( child: Text( - 'v${packageInfo.data!.version}', + 'v$appVersion', style: context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant.withValues( alpha: 0.6, diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b207..bdfacf7d67 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,17 +3,24 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'shared/client/client_headers.dart'; import 'shared/theme/theme_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Pre-load preferences so the first frame uses the saved theme/accent. - final prefs = await SharedPreferences.getInstance(); + final (prefs, clientHeaders) = await ( + SharedPreferences.getInstance(), + loadClientHeaders(), + ).wait; runApp( ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + clientHeadersProvider.overrideWithValue(clientHeaders), + ], child: const App(), ), ); diff --git a/mobile/lib/shared/client/client_headers.dart b/mobile/lib/shared/client/client_headers.dart new file mode 100644 index 0000000000..24162bcb28 --- /dev/null +++ b/mobile/lib/shared/client/client_headers.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +const _buzzClientHeaderName = 'Buzz-Client'; +const _userAgentHeaderName = 'User-Agent'; + +/// Immutable HTTP identification headers for the Buzz mobile client. +@immutable +class ClientHeaders { + final String appVersion; + final String buzzClient; + final String userAgent; + + const ClientHeaders({ + required this.appVersion, + required this.buzzClient, + required this.userAgent, + }); + + Map get values { + if (buzzClient.isEmpty && userAgent.isEmpty) return const {}; + if (buzzClient.isEmpty || userAgent.isEmpty) { + throw StateError('Buzz client headers must be initialized together'); + } + return Map.unmodifiable({ + _buzzClientHeaderName: buzzClient, + _userAgentHeaderName: userAgent, + }); + } +} + +/// Metadata used to construct [ClientHeaders]. +@immutable +class ClientHeaderMetadata { + final String platform; + final String appVersion; + final String appBuild; + final String osVersion; + final int? osApi; + + const ClientHeaderMetadata({ + required this.platform, + required this.appVersion, + required this.appBuild, + required this.osVersion, + this.osApi, + }); +} + +/// Builds the canonical structured `Buzz-Client` and display-only `User-Agent`. +ClientHeaders buildClientHeaders(ClientHeaderMetadata metadata) { + if (metadata.platform != 'ios' && metadata.platform != 'android') { + throw ArgumentError.value( + metadata.platform, + 'platform', + 'must be ios or android', + ); + } + if (metadata.platform == 'android' && + (metadata.osApi == null || metadata.osApi! <= 0)) { + throw ArgumentError('Android client headers require a positive osApi'); + } + if (metadata.platform == 'ios' && metadata.osApi != null) { + throw ArgumentError('iOS client headers must not include osApi'); + } + if (metadata.appBuild.isEmpty || + !metadata.appBuild.codeUnits.every( + (codeUnit) => codeUnit >= 0x30 && codeUnit <= 0x39, + )) { + throw FormatException('App build must contain only decimal digits'); + } + + final buzzClient = StringBuffer( + 'v=1, app=buzz-mobile, platform=${metadata.platform}, ' + 'app-version=${_structuredString(metadata.appVersion)}, ' + 'app-build=${_structuredString(metadata.appBuild)}, ' + 'os-version=${_structuredString(metadata.osVersion)}', + ); + if (metadata.osApi case final osApi?) { + buzzClient.write(', os-api=$osApi'); + } + + return ClientHeaders( + appVersion: metadata.appVersion, + buzzClient: buzzClient.toString(), + userAgent: + 'Buzz/${_userAgentProduct(metadata.appVersion)} ' + '(${metadata.platform}; build ${_userAgentComment(metadata.appBuild)})', + ); +} + +String _structuredString(String value) { + final escaped = StringBuffer('"'); + for (final codeUnit in value.codeUnits) { + if (codeUnit < 0x20 || codeUnit > 0x7e) { + throw FormatException( + 'Buzz-Client structured strings must contain visible ASCII', + ); + } + if (codeUnit == 0x22 || codeUnit == 0x5c) { + escaped.writeCharCode(0x5c); + } + escaped.writeCharCode(codeUnit); + } + escaped.write('"'); + return escaped.toString(); +} + +String _userAgentProduct(String value) { + if (value.isEmpty || !_isHttpToken(value)) { + throw FormatException( + 'App version must be a valid User-Agent product token', + ); + } + return value; +} + +String _userAgentComment(String value) { + if (value.isEmpty || + value.codeUnits.any( + (codeUnit) => codeUnit < 0x20 || codeUnit > 0x7e || codeUnit == 0x29, + )) { + throw FormatException('App build must be valid in a User-Agent comment'); + } + return value; +} + +bool _isHttpToken(String value) { + const separators = '()<>@,;:\\"/[]?={} \t'; + return value.codeUnits.every( + (codeUnit) => + codeUnit >= 0x21 && + codeUnit <= 0x7e && + !separators.contains(String.fromCharCode(codeUnit)), + ); +} + +/// Loads immutable mobile identification headers before the app starts. +Future loadClientHeaders() async { + final packageInfo = await PackageInfo.fromPlatform(); + final deviceInfo = DeviceInfoPlugin(); + + if (Platform.isIOS) { + final ios = await deviceInfo.iosInfo; + return buildClientHeaders( + ClientHeaderMetadata( + platform: 'ios', + appVersion: packageInfo.version, + appBuild: packageInfo.buildNumber, + osVersion: _coarseOsVersion(ios.systemVersion), + ), + ); + } + if (Platform.isAndroid) { + final android = await deviceInfo.androidInfo; + return buildClientHeaders( + ClientHeaderMetadata( + platform: 'android', + appVersion: packageInfo.version, + appBuild: packageInfo.buildNumber, + osVersion: _coarseOsVersion(android.version.release), + osApi: android.version.sdkInt, + ), + ); + } + + throw UnsupportedError('Buzz mobile supports only iOS and Android'); +} + +/// Process-cached identification headers, overridden with platform data in main. +/// +/// Tests and component previews do not execute [main], so the provider has an +/// inert value until the app-level override installs real platform metadata. +const _uninitializedClientHeaders = ClientHeaders( + appVersion: '', + buzzClient: '', + userAgent: '', +); + +final clientHeadersProvider = Provider( + (ref) => _uninitializedClientHeaders, +); + +String _coarseOsVersion(String version) { + final match = RegExp(r'^(\d+)(?:\.(\d+))?').firstMatch(version.trim()); + if (match == null) { + throw FormatException('OS version does not start with a numeric release'); + } + final minor = match.group(2); + return minor == null ? match.group(1)! : '${match.group(1)}.$minor'; +} + +/// Returns true when [targetUrl] is a Buzz-owned first-party origin. +/// +/// The active relay origin is trusted explicitly. Hosted Buzz community and +/// pairing domains are trusted by suffix/exact match. Local origins are only +/// trusted in debug builds. +bool isFirstPartyBuzzUrl( + String targetUrl, { + String? relayUrl, + bool allowLocalDevelopment = kDebugMode, +}) { + final target = Uri.tryParse(targetUrl); + if (target == null || target.host.isEmpty || !_isHttpOrWebSocket(target)) { + return false; + } + + if (relayUrl case final configuredRelay?) { + final relay = Uri.tryParse(configuredRelay); + if (relay != null && _sameOrigin(target, relay)) return true; + } + + final host = target.host.toLowerCase(); + if (host == 'pairing.buzz.xyz' || host.endsWith('.communities.buzz.xyz')) { + return target.scheme == 'https' || target.scheme == 'wss'; + } + + if (allowLocalDevelopment && _isLocalHost(host)) { + return true; + } + return false; +} + +Map clientHeadersForUrl({ + required ClientHeaders headers, + required String targetUrl, + String? relayUrl, + bool allowLocalDevelopment = kDebugMode, +}) { + if (!isFirstPartyBuzzUrl( + targetUrl, + relayUrl: relayUrl, + allowLocalDevelopment: allowLocalDevelopment, + )) { + return const {}; + } + return headers.values; +} + +bool _isHttpOrWebSocket(Uri uri) => + uri.scheme == 'http' || + uri.scheme == 'https' || + uri.scheme == 'ws' || + uri.scheme == 'wss'; + +bool _sameOrigin(Uri a, Uri b) { + final aSecure = _isSecureScheme(a.scheme); + final bSecure = _isSecureScheme(b.scheme); + return aSecure != null && + aSecure == bSecure && + a.host.toLowerCase() == b.host.toLowerCase() && + _effectivePort(a) == _effectivePort(b); +} + +bool? _isSecureScheme(String scheme) => switch (scheme) { + 'https' || 'wss' => true, + 'http' || 'ws' => false, + _ => null, +}; + +int? _effectivePort(Uri uri) { + if (uri.hasPort) return uri.port; + return switch (uri.scheme) { + 'https' || 'wss' => 443, + 'http' || 'ws' => 80, + _ => null, + }; +} + +bool _isLocalHost(String host) => + host == 'localhost' || host == '127.0.0.1' || host == '::1'; diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 3982fb48d1..4ca08899b5 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../client/client_headers.dart'; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; @@ -15,9 +16,10 @@ const _mediaGetAuthRefreshMarginSeconds = 60; /// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs. /// -/// Returns an empty map for non-relay URLs or when no signing key is available, -/// so callers can safely use this on arbitrary profile/custom-emoji URLs without -/// leaking Buzz credentials to third-party hosts. +/// Returns only first-party client-identification headers when no signing key +/// is available. Returns an empty map for non-relay URLs, so callers can safely +/// use this on arbitrary profile/custom-emoji URLs without leaking Buzz +/// metadata or credentials to third-party hosts. /// /// The signed header is memoized until [_mediaGetAuthRefreshMarginSeconds] /// before expiry: repeated calls return the byte-identical map instead of @@ -27,6 +29,7 @@ const _mediaGetAuthRefreshMarginSeconds = 60; class MediaGetAuthService { final String _baseUrl; final String? _nsec; + final ClientHeaders? _clientHeaders; final DateTime Function() _now; Map? _cachedHeaders; @@ -35,19 +38,30 @@ class MediaGetAuthService { MediaGetAuthService({ required String baseUrl, required String? nsec, + ClientHeaders? clientHeaders, DateTime Function()? now, }) : _baseUrl = baseUrl, _nsec = nsec, + _clientHeaders = clientHeaders, _now = now ?? DateTime.now; Map headersFor(String url) { - final nsec = _nsec; - if (nsec == null || nsec.isEmpty) return const {}; final uri = Uri.tryParse(url); final relayUri = Uri.tryParse(_baseUrl); if (uri == null || relayUri == null) return const {}; if (!_isRelayMediaUrl(uri, relayUri)) return const {}; + final clientHeaders = _clientHeaders; + final identificationHeaders = clientHeaders == null + ? const {} + : clientHeadersForUrl( + headers: clientHeaders, + targetUrl: url, + relayUrl: _baseUrl, + ); + final nsec = _nsec; + if (nsec == null || nsec.isEmpty) return identificationHeaders; + final cached = _cachedHeaders; final refreshAt = _refreshAt; if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) { @@ -61,6 +75,7 @@ class MediaGetAuthService { .encode(utf8.encode(authEvent.toJson())) .replaceAll('=', ''); final headers = Map.unmodifiable({ + ...identificationHeaders, 'Authorization': 'Nostr $encoded', }); _cachedHeaders = headers; @@ -72,25 +87,16 @@ class MediaGetAuthService { ); return headers; } catch (_) { - // Read auth is best-effort: while the relay rollout flag is off, an - // unsigned fetch still works. Once the flag is on, this request will 403 - // instead of crashing the widget tree because local key material is bad. - return const {}; + // Read auth is best-effort: preserve first-party identification headers + // even when local signing material is invalid. + return identificationHeaders; } } bool _isRelayMediaUrl(Uri uri, Uri relayUri) { if (uri.scheme != 'http' && uri.scheme != 'https') return false; if (uri.host.isEmpty || relayUri.host.isEmpty) return false; - // Extract the URL's origin and path. Query strings are ignored for media - // host/path detection, matching the fetch target shape used by descriptors. - final base = '${uri.scheme}://${uri.authority}'; - final mediaAuthority = extractServerAuthority(base); - final relayAuthority = extractServerAuthority(_baseUrl); - if (mediaAuthority == null || relayAuthority == null) return false; - if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) { - return false; - } + if (!_sameHttpOrigin(uri, relayUri)) return false; return uri.path.startsWith('/media/'); } @@ -121,7 +127,11 @@ class MediaGetAuthService { final mediaGetAuthServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); - return MediaGetAuthService(baseUrl: config.baseUrl, nsec: config.nsec); + return MediaGetAuthService( + baseUrl: config.baseUrl, + nsec: config.nsec, + clientHeaders: ref.watch(clientHeadersProvider), + ); }); Map mediaGetHeadersFor(WidgetRef ref, String url) { @@ -145,6 +155,20 @@ String? extractServerAuthority(String baseUrl) { return _normalizeAuthority(authority); } +bool _sameHttpOrigin(Uri a, Uri b) => + a.scheme.toLowerCase() == b.scheme.toLowerCase() && + a.host.toLowerCase() == b.host.toLowerCase() && + _effectiveHttpPort(a) == _effectiveHttpPort(b); + +int? _effectiveHttpPort(Uri uri) { + if (uri.hasPort) return uri.port; + return switch (uri.scheme.toLowerCase()) { + 'https' => 443, + 'http' => 80, + _ => null, + }; +} + String _normalizeAuthority(String authority) { var normalized = authority.trim().toLowerCase(); if (normalized.endsWith('.')) { diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index be84f548ce..eeee07e86a 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -9,6 +9,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import '../client/client_headers.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; import 'relay_provider.dart'; @@ -138,6 +139,7 @@ class MediaUploadService { final TranscodeVideoToMp4 _transcodeVideoToMp4; final ReadClipboardImage _readClipboardImage; final DateTime Function() _now; + final ClientHeaders? _clientHeaders; final http.Client _http; final bool _ownsHttpClient; @@ -151,6 +153,7 @@ class MediaUploadService { TranscodeVideoToMp4? transcodeVideoToMp4, ReadClipboardImage? readClipboardImage, DateTime Function()? now, + ClientHeaders? clientHeaders, http.Client? httpClient, }) : _baseUrl = baseUrl, _nsec = nsec, @@ -162,6 +165,7 @@ class MediaUploadService { _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, _readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage, _now = now ?? DateTime.now, + _clientHeaders = clientHeaders, _http = httpClient ?? http.Client(), _ownsHttpClient = httpClient == null; @@ -297,7 +301,14 @@ class MediaUploadService { required String mimeType, required String sha256, }) { + final clientHeaders = _clientHeaders; final headers = { + if (clientHeaders != null) + ...clientHeadersForUrl( + headers: clientHeaders, + targetUrl: _baseUrl, + relayUrl: _baseUrl, + ), 'Authorization': _buildUploadAuthHeader(sha256), 'Content-Type': mimeType, 'X-SHA-256': sha256, @@ -668,6 +679,7 @@ final mediaUploadServiceProvider = Provider((ref) { final service = MediaUploadService( baseUrl: config.baseUrl, nsec: config.nsec, + clientHeaders: ref.watch(clientHeadersProvider), pickGalleryImage: () => picker.pickImage( source: ImageSource.gallery, requestFullMetadata: false, diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index d168b9a097..8f7e8b3a93 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,3 +1,4 @@ +export '../client/client_headers.dart'; export 'app_lifecycle_provider.dart'; export 'media_auth.dart'; export 'media_image.dart'; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 9c167e10b1..d9753c6010 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -11,6 +11,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; +import '../client/client_headers.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; import 'relay_provider.dart'; @@ -69,15 +70,32 @@ typedef RelaySocketFactory = RelaySocket Function({ required String wsUrl, required String? nsec, + required Map headers, required void Function(List message) onMessage, required void Function() onConnected, required void Function(Object? error) onDisconnected, }); class RelaySessionNotifier extends Notifier { + static RelaySocket _defaultSocketFactory({ + required String wsUrl, + required String? nsec, + required Map headers, + required void Function(List message) onMessage, + required void Function() onConnected, + required void Function(Object? error) onDisconnected, + }) => RelaySocket( + wsUrl: wsUrl, + nsec: nsec, + headers: headers, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ); + RelaySessionNotifier({ http.Client? httpClient, - RelaySocketFactory socketFactory = RelaySocket.new, + RelaySocketFactory socketFactory = _defaultSocketFactory, }) : _httpClient = httpClient, _socketFactory = socketFactory; @@ -143,6 +161,11 @@ class RelaySessionNotifier extends Notifier { .post( Uri.parse(url), headers: { + ...clientHeadersForUrl( + headers: ref.read(clientHeadersProvider), + targetUrl: url, + relayUrl: config.baseUrl, + ), 'Authorization': buildNip98AuthHeader( method: 'POST', url: url, @@ -361,6 +384,11 @@ class RelaySessionNotifier extends Notifier { final socket = _socketFactory( wsUrl: config.wsUrl, nsec: config.nsec, + headers: clientHeadersForUrl( + headers: ref.read(clientHeadersProvider), + targetUrl: config.wsUrl, + relayUrl: config.baseUrl, + ), onMessage: (message) { if (generation == _connectionGeneration) _handleMessage(message); }, diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 267b030391..6013cc7fed 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'nostr_models.dart'; @@ -32,6 +33,7 @@ Exception classifyRelayAuthFailure(String message) { class RelaySocket { final String _wsUrl; final String? _nsec; + final Map _headers; final void Function(List message) _onMessage; final void Function() _onConnected; final void Function(Object? error) _onDisconnected; @@ -48,11 +50,13 @@ class RelaySocket { RelaySocket({ required String wsUrl, required String? nsec, + Map headers = const {}, required void Function(List message) onMessage, required void Function() onConnected, required void Function(Object? error) onDisconnected, }) : _wsUrl = wsUrl, _nsec = nsec, + _headers = Map.unmodifiable(headers), _onMessage = onMessage, _onConnected = onConnected, _onDisconnected = onDisconnected; @@ -63,7 +67,10 @@ class RelaySocket { _state = SocketState.connecting; try { - _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); + _channel = IOWebSocketChannel.connect( + Uri.parse(_wsUrl), + headers: _headers, + ); await _channel!.ready; } catch (e) { _state = SocketState.disconnected; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index dc062b8628..8e94fe6555 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -289,6 +289,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" elliptic: dependency: transitive description: @@ -1421,6 +1437,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3e2e1f83c4..32cde1f682 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: app_badge_plus: ^1.2.10 app_links: ^6.4.0 scrollable_positioned_list: ^0.3.8 + device_info_plus: ^13.0.0 dev_dependencies: flutter_test: diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 29158f7910..f9c597032f 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -8,10 +8,17 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/client/client_headers.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; import '../../shared/community/community_storage_test.dart'; +const _clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'Buzz/1.0 (ios; build 1)', +); + void main() { for (final existingRelayUrl in [ 'wss://relay.example.com', @@ -35,6 +42,7 @@ void main() { final auth = _RecordingAuthNotifier(); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() { @@ -84,6 +92,7 @@ void main() { final auth = _RecordingAuthNotifier(); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), @@ -129,6 +138,11 @@ void main() { ); expect(capturedRequest!.body, jsonEncode({'code': 'code'})); expect(capturedRequest!.headers['Authorization'], startsWith('Nostr ')); + expect( + capturedRequest!.headers['Buzz-Client'], + _clientHeaders.buzzClient, + ); + expect(capturedRequest!.headers['User-Agent'], _clientHeaders.userAgent); expect(auth.authenticatedCommunities, hasLength(1)); expect( auth.authenticatedCommunities.single.relayUrl, @@ -145,6 +159,7 @@ void main() { final storage = CommunityStorage(secure: FakeSecureStorage()); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), communityStorageProvider.overrideWithValue(storage), inviteKeyGeneratorProvider.overrideWithValue(() => keys), inviteJoinHttpClientProvider.overrideWithValue( @@ -191,6 +206,7 @@ void main() { final auth = _RecordingAuthNotifier(); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 6f49f71921..40996f7aca 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/features/pairing/pairing_socket.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/client/client_headers.dart'; /// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and /// SSRF-prevention validation. @@ -23,6 +24,12 @@ import 'package:buzz/shared/auth/auth.dart'; /// wrong shape (non-object, missing fields, missing nsec), and SSRF /// guards (private IPs, non-http schemes). /// - `reset()` returning to idle from an error state. +const _clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'Buzz/1.0 (ios; build 1)', +); + void main() { group('PairingNotifier', () { late ProviderContainer container; @@ -31,7 +38,10 @@ void main() { ProviderContainer createContainer() { fakeAuth = FakeAuthNotifier(); return ProviderContainer( - overrides: [authProvider.overrideWith(() => fakeAuth)], + overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), + authProvider.overrideWith(() => fakeAuth), + ], ); } @@ -52,12 +62,16 @@ void main() { ({ required wsUrl, required ephemeralPrivkey, + required headers, required onMessage, required void Function(Object? error) onDisconnected, }) => _DisconnectingSocket(disconnectCallback: onDisconnected), ); container = ProviderContainer( - overrides: [pairingProvider.overrideWith(() => notifier)], + overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), + pairingProvider.overrideWith(() => notifier), + ], ); const code = 'nostrpair://62287897da61e3fa294b4570575f7db8bea147d6631150f2e4656714c645fb1e' diff --git a/mobile/test/features/pairing/pairing_socket_test.dart b/mobile/test/features/pairing/pairing_socket_test.dart index ce785009b8..b3426108ad 100644 --- a/mobile/test/features/pairing/pairing_socket_test.dart +++ b/mobile/test/features/pairing/pairing_socket_test.dart @@ -110,13 +110,35 @@ void main() { expect(socket.isConnected, isFalse); }); + test('passes identification headers to the WebSocket handshake', () async { + Map? capturedHeaders; + final channel = _ControlledWebSocketChannel(); + final socket = PairingSocket( + wsUrl: 'ws://unused', + ephemeralPrivkey: _privateKey, + headers: const {'Buzz-Client': 'client', 'User-Agent': 'agent'}, + onMessage: (_) {}, + onDisconnected: (_) {}, + authChallengeTimeout: Duration.zero, + channelFactory: (_, headers) { + capturedHeaders = headers; + return channel; + }, + ); + addTearDown(socket.disconnect); + + await socket.connect(); + + expect(capturedHeaders, {'Buzz-Client': 'client', 'User-Agent': 'agent'}); + }); + test('notifies once when a connected stream emits an error', () async { var disconnectCount = 0; final channel = _ControlledWebSocketChannel(); final socket = _socket( 'ws://unused', onDisconnected: (_) => disconnectCount++, - channelFactory: (_) => channel, + channelFactory: (_, _) => channel, authChallengeTimeout: Duration.zero, ); addTearDown(socket.disconnect); @@ -134,7 +156,7 @@ void main() { final socket = _socket( 'ws://unused', onDisconnected: (_) => disconnectCount++, - channelFactory: (_) => channel, + channelFactory: (_, _) => channel, authChallengeTimeout: Duration.zero, ); addTearDown(socket.disconnect); @@ -153,7 +175,7 @@ void main() { final disconnectingSocket = _socket( 'ws://unused', onDisconnected: (_) => disconnectCount++, - channelFactory: (_) => disconnectChannel, + channelFactory: (_, _) => disconnectChannel, authChallengeTimeout: Duration.zero, ); await disconnectingSocket.connect(); @@ -164,7 +186,7 @@ void main() { final disposingSocket = _socket( 'ws://unused', onDisconnected: (_) => disconnectCount++, - channelFactory: (_) => disposeChannel, + channelFactory: (_, _) => disposeChannel, authChallengeTimeout: Duration.zero, ); await disposingSocket.connect(); @@ -183,7 +205,7 @@ PairingSocket _socket( Duration authChallengeTimeout = const Duration(milliseconds: 500), Duration authResponseTimeout = const Duration(seconds: 10), void Function(Object? error)? onDisconnected, - WebSocketChannel Function(Uri uri)? channelFactory, + PairingChannelFactory? channelFactory, }) => PairingSocket( wsUrl: url, ephemeralPrivkey: _privateKey, @@ -191,7 +213,8 @@ PairingSocket _socket( onDisconnected: onDisconnected ?? (_) {}, authChallengeTimeout: authChallengeTimeout, authResponseTimeout: authResponseTimeout, - channelFactory: channelFactory ?? WebSocketChannel.connect, + channelFactory: + channelFactory ?? (uri, headers) => WebSocketChannel.connect(uri), ); class _ControlledWebSocketChannel implements WebSocketChannel { diff --git a/mobile/test/shared/client/client_headers_test.dart b/mobile/test/shared/client/client_headers_test.dart new file mode 100644 index 0000000000..381450106e --- /dev/null +++ b/mobile/test/shared/client/client_headers_test.dart @@ -0,0 +1,164 @@ +import 'package:buzz/shared/client/client_headers.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('buildClientHeaders', () { + test('builds canonical iOS headers and escapes structured strings', () { + final headers = buildClientHeaders( + const ClientHeaderMetadata( + platform: 'ios', + appVersion: '0.4.5', + appBuild: '6', + osVersion: '18.5"preview', + ), + ); + + expect( + headers.buzzClient, + r'v=1, app=buzz-mobile, platform=ios, app-version="0.4.5", ' + r'app-build="6", os-version="18.5\"preview"', + ); + expect(headers.appVersion, '0.4.5'); + expect(headers.userAgent, 'Buzz/0.4.5 (ios; build 6)'); + }); + + test('builds canonical Android headers with the API level', () { + final headers = buildClientHeaders( + const ClientHeaderMetadata( + platform: 'android', + appVersion: '0.4.5', + appBuild: '6', + osVersion: '15', + osApi: 35, + ), + ); + + expect( + headers.buzzClient, + 'v=1, app=buzz-mobile, platform=android, ' + 'app-version="0.4.5", app-build="6", os-version="15", os-api=35', + ); + expect(headers.userAgent, 'Buzz/0.4.5 (android; build 6)'); + }); + + test('does not emit partially initialized header pairs', () { + expect( + const ClientHeaders( + appVersion: '', + buzzClient: '', + userAgent: '', + ).values, + isEmpty, + ); + expect( + () => const ClientHeaders( + appVersion: '1', + buzzClient: 'client', + userAgent: '', + ).values, + throwsStateError, + ); + }); + + test('rejects non-decimal app builds', () { + for (final build in ['', '1.beta', r'6\beta']) { + expect( + () => buildClientHeaders( + ClientHeaderMetadata( + platform: 'ios', + appVersion: '1.0', + appBuild: build, + osVersion: '18.0', + ), + ), + throwsFormatException, + reason: build, + ); + } + }); + + test('rejects invalid platform-specific metadata', () { + for (final osApi in [null, 0, -1]) { + expect( + () => buildClientHeaders( + ClientHeaderMetadata( + platform: 'android', + appVersion: '1.0', + appBuild: '1', + osVersion: '15', + osApi: osApi, + ), + ), + throwsArgumentError, + reason: '$osApi', + ); + } + expect( + () => buildClientHeaders( + const ClientHeaderMetadata( + platform: 'ios', + appVersion: '1.0', + appBuild: '1', + osVersion: '18.0', + osApi: 35, + ), + ), + throwsArgumentError, + ); + }); + }); + + group('first-party origin gating', () { + const headers = ClientHeaders( + appVersion: '1.0', + buzzClient: 'client', + userAgent: 'agent', + ); + + test('allows the configured relay across HTTP and WebSocket schemes', () { + expect( + clientHeadersForUrl( + headers: headers, + targetUrl: 'wss://relay.example:443/socket', + relayUrl: 'https://Relay.Example/base', + allowLocalDevelopment: false, + ), + {'Buzz-Client': 'client', 'User-Agent': 'agent'}, + ); + }); + + test('allows hosted communities and the dedicated pairing relay', () { + for (final url in [ + 'wss://acme.communities.buzz.xyz', + 'https://PAIRING.BUZZ.XYZ/path', + ]) { + expect( + isFirstPartyBuzzUrl(url, allowLocalDevelopment: false), + isTrue, + reason: url, + ); + } + }); + + test('rejects lookalikes, third-party hosts, and insecure hosted URLs', () { + for (final url in [ + 'wss://communities.buzz.xyz.evil.example', + 'wss://evil.example', + 'ws://pairing.buzz.xyz', + 'https://acme.communities.buzz.xyz.evil.example', + 'wss://relay.example:444/socket', + ]) { + expect( + clientHeadersForUrl( + headers: headers, + targetUrl: url, + relayUrl: 'https://relay.example', + allowLocalDevelopment: false, + ), + isEmpty, + reason: url, + ); + } + }); + }); +} diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 4f43ec4770..1bc139dcd9 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:async'; +import 'package:buzz/shared/client/client_headers.dart'; import 'package:buzz/shared/relay/media_auth.dart'; import 'package:buzz/shared/relay/media_image.dart'; import 'package:flutter/painting.dart'; @@ -18,8 +19,16 @@ final _pngBytes = base64Decode( const _relayBase = 'https://relay.example.com'; const _mediaUrl = '$_relayBase/media/abc123.png'; -MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) => - MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now); +MediaGetAuthService _auth({ + String? nsec, + DateTime Function()? now, + ClientHeaders? clientHeaders, +}) => MediaGetAuthService( + baseUrl: _relayBase, + nsec: nsec, + now: now, + clientHeaders: clientHeaders, +); void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -57,10 +66,43 @@ void main() { expect(refreshed['Authorization'], isNot(first['Authorization'])); }); + test('sends identification only for relay media URLs', () { + const clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'test-agent', + ); + final auth = _auth( + nsec: nostr.Keys.generate().nsec, + clientHeaders: clientHeaders, + ); + + expect( + auth.headersFor(_mediaUrl), + containsPair('Buzz-Client', 'test-client'), + ); + expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty); + }); + + test('sends identification without a signing key', () { + const clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'test-agent', + ); + final auth = _auth(clientHeaders: clientHeaders); + + expect(auth.headersFor(_mediaUrl), clientHeaders.values); + }); + test('non-relay URLs get no headers even with a key', () { final nsec = nostr.Keys.generate().nsec; final auth = _auth(nsec: nsec); expect(auth.headersFor('https://elsewhere.com/media/abc.png'), isEmpty); + expect( + auth.headersFor('http://relay.example.com/media/abc.png'), + isEmpty, + ); expect(auth.headersFor('$_relayBase/not-media/abc.png'), isEmpty); }); }); diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 94b23c9f79..4b664a4e57 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -8,6 +8,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:image_picker/image_picker.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/shared/client/client_headers.dart'; import 'package:buzz/shared/relay/media_auth.dart'; import 'package:buzz/shared/relay/media_upload.dart'; @@ -346,6 +347,11 @@ void main() { final service = MediaUploadService( baseUrl: 'https://relay.example:8443', nsec: nsec, + clientHeaders: const ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'test-agent', + ), httpClient: client, pickGalleryVideo: () async => null, pickGalleryImage: () async => @@ -364,6 +370,8 @@ void main() { ); expect(capturedRequest!.headers['Content-Type'], 'image/png'); expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty); + expect(capturedRequest!.headers['Buzz-Client'], 'test-client'); + expect(capturedRequest!.headers['User-Agent'], 'test-agent'); expect(capturedRequest!.bodyBytes, _pngBytes); final authHeader = capturedRequest!.headers['Authorization']; diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 6cd601f56b..d4b547df96 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -10,6 +10,12 @@ import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +const _clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'Buzz/1.0 (ios; build 1)', +); + void main() { test('queryRelay sends NIP-98 auth over POST /query', () async { final keychain = nostr.Keys.generate(); @@ -22,6 +28,7 @@ void main() { final session = RelaySessionNotifier(httpClient: client); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -52,6 +59,8 @@ void main() { expect(capturedRequest!.method, 'POST'); expect(capturedRequest!.url.toString(), 'https://relay.example/query'); expect(capturedRequest!.headers['Content-Type'], 'application/json'); + expect(capturedRequest!.headers['Buzz-Client'], _clientHeaders.buzzClient); + expect(capturedRequest!.headers['User-Agent'], _clientHeaders.userAgent); expect(jsonDecode(capturedRequest!.body), [filter.toJson()]); final authHeader = capturedRequest!.headers['Authorization']; @@ -88,6 +97,7 @@ void main() { ); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith( () => _FakeRelayConfigNotifier( @@ -174,6 +184,7 @@ void main() { final auth = _FakeAuthNotifier(); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), relaySessionProvider.overrideWith(() => session), authProvider.overrideWith(() => auth), ], @@ -199,6 +210,7 @@ void main() { ({ required wsUrl, required nsec, + required headers, required onMessage, required onConnected, required onDisconnected, @@ -206,6 +218,7 @@ void main() { final socket = _ControlledRelaySocket( wsUrl: wsUrl, nsec: nsec, + headers: headers, onMessage: onMessage, onConnected: onConnected, onDisconnected: onDisconnected, @@ -220,6 +233,7 @@ void main() { ); final container = ProviderContainer( overrides: [ + clientHeadersProvider.overrideWithValue(_clientHeaders), relaySessionProvider.overrideWith(() => session), relayConfigProvider.overrideWith(() => config), authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), @@ -230,6 +244,11 @@ void main() { final subscription = container.listen(relaySessionProvider, (_, _) {}); addTearDown(subscription.close); await Future.delayed(Duration.zero); + expect(sockets, hasLength(1)); + expect(sockets.single.capturedHeaders, { + 'Buzz-Client': _clientHeaders.buzzClient, + 'User-Agent': _clientHeaders.userAgent, + }); config.update(baseUrl: 'https://new.example', nsec: keychain.nsec); await Future.delayed(Duration.zero); @@ -372,14 +391,17 @@ class _AuthenticatedAuthNotifier extends AuthNotifier { class _ControlledRelaySocket extends RelaySocket { final void Function() _connected; final void Function(Object? error) _disconnected; + final Map capturedHeaders; _ControlledRelaySocket({ required super.wsUrl, required super.nsec, + required super.headers, required super.onMessage, required super.onConnected, required super.onDisconnected, - }) : _connected = onConnected, + }) : capturedHeaders = headers, + _connected = onConnected, _disconnected = onDisconnected; @override diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index a72b34852a..faa120a4f9 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -3,8 +3,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:buzz/app.dart'; import 'package:buzz/shared/auth/auth.dart'; +import 'package:buzz/shared/client/client_headers.dart'; import 'package:buzz/shared/theme/theme_provider.dart'; +const _clientHeaders = ClientHeaders( + appVersion: '1.0', + buzzClient: 'test-client', + userAgent: 'Buzz/1.0 (ios; build 1)', +); + void main() { testWidgets('App renders pairing page when unauthenticated', ( WidgetTester tester, @@ -16,6 +23,7 @@ void main() { ProviderScope( overrides: [ authProvider.overrideWith(() => _FakeAuthNotifier()), + clientHeadersProvider.overrideWithValue(_clientHeaders), savedPrefsProvider.overrideWithValue(prefs), ], child: const App(),