mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
## Summary - downgrade Mobile Huddle authentication and native media configuration from protocol v3 to the currently deployed relay's v2 contract - restore the released one-byte relay peer prefix while retaining later reconnect, roster, and playout-reset reliability fixes - update Android, iOS, protocol documentation, and focused tests together Protocol v2 does not carry v3's occupancy epoch on audio frames, so it cannot fence the narrow delayed-packet/peer-index-reuse race. This is an intentional compatibility tradeoff until the relay v3 rollout is ready. ### Related issue None found. ### Testing - `just mobile-check` - `just mobile-test` — 1,661 tests passed - Android debug build installed and launched on Pixel 10 as `xyz.block.buzz.mobile.sprout_mobile_profile_settings`; foreground process verified - signed iOS Release build installed and launched on iPhone as `com.buzz.buzzMobile`; running process verified A live two-device Huddle audio call remains a manual verification step. Signed-off-by: kenny lopez <klopez4212@gmail.com>
125 lines
3.6 KiB
Dart
125 lines
3.6 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../relay/nostr_models.dart';
|
|
import 'huddle_wire.dart';
|
|
|
|
/// Immutable connection inputs for one Huddle audio WebSocket.
|
|
@immutable
|
|
final class HuddleConnectionParameters {
|
|
final String relayWebSocketUrl;
|
|
final String nsec;
|
|
final String parentChannelId;
|
|
final String ephemeralChannelId;
|
|
|
|
HuddleConnectionParameters({
|
|
required this.relayWebSocketUrl,
|
|
required this.nsec,
|
|
required this.parentChannelId,
|
|
required this.ephemeralChannelId,
|
|
}) {
|
|
final relayUri = Uri.tryParse(relayWebSocketUrl);
|
|
if (relayUri == null ||
|
|
!relayUri.hasAuthority ||
|
|
(relayUri.scheme != 'ws' && relayUri.scheme != 'wss')) {
|
|
throw ArgumentError.value(
|
|
relayWebSocketUrl,
|
|
'relayWebSocketUrl',
|
|
'must be an absolute ws:// or wss:// relay URL',
|
|
);
|
|
}
|
|
if (relayUri.hasQuery || relayUri.hasFragment) {
|
|
throw ArgumentError.value(
|
|
relayWebSocketUrl,
|
|
'relayWebSocketUrl',
|
|
'must not contain a query or fragment',
|
|
);
|
|
}
|
|
_validateUuid(parentChannelId, 'parentChannelId');
|
|
_validateUuid(ephemeralChannelId, 'ephemeralChannelId');
|
|
if (nsec.trim().isEmpty) {
|
|
throw ArgumentError.value(nsec, 'nsec', 'must not be empty');
|
|
}
|
|
}
|
|
|
|
/// The endpoint is derived from the relay origin, but NIP-42's `relay` tag
|
|
/// must remain [relayWebSocketUrl], not this Huddle-specific URL.
|
|
Uri get audioWebSocketUri {
|
|
final base = Uri.parse(relayWebSocketUrl);
|
|
return base.replace(
|
|
path: '/huddle/${Uri.encodeComponent(ephemeralChannelId)}/audio',
|
|
query: null,
|
|
fragment: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Fixed NIP-42 + Huddle auth envelope used after the audio relay challenge.
|
|
abstract final class HuddleAuthV2 {
|
|
static Map<String, dynamic> buildMessage({
|
|
required HuddleConnectionParameters parameters,
|
|
required String challenge,
|
|
int? createdAt,
|
|
}) {
|
|
if (challenge.isEmpty) {
|
|
throw const HuddleAuthException('Relay challenge must not be empty.');
|
|
}
|
|
|
|
final secretKey = _decodeSecretKey(parameters.nsec);
|
|
final event = nostr.Event.from(
|
|
kind: EventKind.auth,
|
|
content: '',
|
|
tags: [
|
|
['relay', parameters.relayWebSocketUrl],
|
|
['challenge', challenge],
|
|
],
|
|
secretKey: secretKey,
|
|
createdAt: createdAt ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
verify: false,
|
|
);
|
|
|
|
return {
|
|
'type': 'auth',
|
|
'event': event.toMap(),
|
|
'parent_channel_id': parameters.parentChannelId,
|
|
'protocol_version': HuddleWireV2.protocolVersion,
|
|
};
|
|
}
|
|
|
|
static String _decodeSecretKey(String value) {
|
|
final trimmed = value.trim();
|
|
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(trimmed)) {
|
|
return trimmed.toLowerCase();
|
|
}
|
|
try {
|
|
final decoded = nostr.Nip19.decode(payload: trimmed).data;
|
|
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(decoded)) {
|
|
return decoded.toLowerCase();
|
|
}
|
|
} catch (_) {
|
|
// Fall through to the stable domain error below.
|
|
}
|
|
throw const HuddleAuthException('Invalid Nostr signing key.');
|
|
}
|
|
}
|
|
|
|
final class HuddleAuthException implements Exception {
|
|
final String message;
|
|
|
|
const HuddleAuthException(this.message);
|
|
|
|
@override
|
|
String toString() => 'HuddleAuthException: $message';
|
|
}
|
|
|
|
final _canonicalUuid = RegExp(
|
|
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-'
|
|
r'[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
|
);
|
|
|
|
void _validateUuid(String value, String name) {
|
|
if (!_canonicalUuid.hasMatch(value)) {
|
|
throw ArgumentError.value(value, name, 'must be a canonical UUID');
|
|
}
|
|
}
|