feat(mobile): identify clients to Buzz servers

Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@buzz.block.builderlab.xyz>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@buzz.block.builderlab.xyz>
This commit is contained in:
npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
2026-07-23 12:21:10 -07:00
parent acfbb1bb6a
commit 454719ff5b
27 changed files with 1053 additions and 51 deletions

14
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"]

View File

@@ -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<i64>,
}
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<Self> {
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<Self, ()> {
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<S> OptionalFromRequestParts<S> for ClientInfo
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Option<Self>, 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<String, ()> {
bare_item(dictionary, key)?
.as_token()
.map(|value| value.as_str().to_owned())
.ok_or(())
}
fn string(dictionary: &Dictionary, key: &str) -> Result<String, ()> {
bare_item(dictionary, key)?
.as_string()
.map(|value| value.as_str().to_owned())
.ok_or(())
}
fn integer(dictionary: &Dictionary, key: &str) -> Result<i64, ()> {
bare_item(dictionary, key)?
.as_integer()
.map(Into::into)
.ok_or(())
}
fn optional_integer(dictionary: &Dictionary, key: &str) -> Result<Option<i64>, ()> {
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}");
}
}
}

View File

@@ -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<AppState>,
addr: SocketAddr,
tenant: TenantContext,
client_info: Option<ClientInfo>,
) {
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<ClientInfo>,
) {
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()

View File

@@ -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

View File

@@ -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 {

View File

@@ -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<InviteJoinState> {
.post(
Uri.parse(url),
headers: {
...clientHeadersForUrl(
headers: ref.read(clientHeadersProvider),
targetUrl: url,
relayUrl: invite.relayUrl,
),
'Authorization': buildNip98AuthHeader(
method: 'POST',
url: url,

View File

@@ -61,6 +61,7 @@ typedef PairingSocketFactory =
PairingSocket Function({
required String wsUrl,
required String ephemeralPrivkey,
required Map<String, String> headers,
required void Function(List<dynamic> message) onMessage,
required void Function(Object? error) onDisconnected,
});
@@ -76,11 +77,13 @@ class PairingNotifier extends Notifier<PairingState> {
static PairingSocket _createPairingSocket({
required String wsUrl,
required String ephemeralPrivkey,
required Map<String, String> headers,
required void Function(List<dynamic> 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<PairingState> {
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<PairingState> {
final socket = RelaySocket(
wsUrl: wsUrl,
nsec: nsec,
headers: clientHeadersForUrl(
headers: ref.read(clientHeadersProvider),
targetUrl: wsUrl,
),
onMessage: (_) {},
onConnected: () {},
onDisconnected: (_) {},

View File

@@ -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<String, String> 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<String, String> _headers;
final PairingChannelFactory _channelFactory;
WebSocketChannel? _channel;
StreamSubscription<dynamic>? _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<String, String> 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<String, String> headers,
) => IOWebSocketChannel.connect(uri, headers: headers);
bool get isConnected => _connected;
/// Connect and answer a NIP-42 challenge when the relay requires one.
Future<void> connect() async {
_channel = _channelFactory(Uri.parse(_wsUrl));
_channel = _channelFactory(Uri.parse(_wsUrl), _headers);
await _channel!.ready;
_authCompleter = Completer<void>();

View File

@@ -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,

View File

@@ -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(),
),
);

View File

@@ -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<String, String> 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<ClientHeaders> 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<ClientHeaders>(
(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<String, String> 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';

View File

@@ -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<String, String>? _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<String, String> 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 <String, String>{}
: 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<String, String>.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<MediaGetAuthService>((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<String, String> 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('.')) {

View File

@@ -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 = <String, String>{
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<MediaUploadService>((ref) {
final service = MediaUploadService(
baseUrl: config.baseUrl,
nsec: config.nsec,
clientHeaders: ref.watch(clientHeadersProvider),
pickGalleryImage: () => picker.pickImage(
source: ImageSource.gallery,
requestFullMetadata: false,

View File

@@ -1,3 +1,4 @@
export '../client/client_headers.dart';
export 'app_lifecycle_provider.dart';
export 'media_auth.dart';
export 'media_image.dart';

View File

@@ -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<String, String> headers,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
});
class RelaySessionNotifier extends Notifier<SessionState> {
static RelaySocket _defaultSocketFactory({
required String wsUrl,
required String? nsec,
required Map<String, String> headers,
required void Function(List<dynamic> 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<SessionState> {
.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<SessionState> {
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);
},

View File

@@ -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<String, String> _headers;
final void Function(List<dynamic> 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<String, String> headers = const {},
required void Function(List<dynamic> 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;

View File

@@ -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:

View File

@@ -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:

View File

@@ -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),

View File

@@ -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'

View File

@@ -110,13 +110,35 @@ void main() {
expect(socket.isConnected, isFalse);
});
test('passes identification headers to the WebSocket handshake', () async {
Map<String, String>? 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 {

View File

@@ -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,
);
}
});
});
}

View File

@@ -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);
});
});

View File

@@ -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'];

View File

@@ -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<void>.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<void>.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<String, String> 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

View File

@@ -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(),