Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/data/models/enums/storage_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ enum SharedPreferencesKeys {
mostroCustomNodes('mostro_custom_nodes'),
trustedNodeMetadata('trusted_node_metadata'),
backgroundFilters('background_filters'),
communitySelected('community_selected');
communitySelected('community_selected'),
nodeProtocolVersions('node_protocol_versions');

final String value;

Expand Down
16 changes: 14 additions & 2 deletions lib/data/models/nostr_event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,14 @@ extension NostrEventExtensions on NostrEvent {
return timeago.format(createdAt!, allowFromNow: true, locale: locale);
}

Future<NostrEvent> unWrap(String privateKey) async {
Future<NostrEvent> unWrap(
String privateKey, {
required String expectedAuthor,
}) async {
return await NostrUtils.decryptNIP59Event(
this,
privateKey,
expectedAuthor: expectedAuthor,
);
}

Expand Down Expand Up @@ -104,7 +108,10 @@ extension NostrEventExtensions on NostrEvent {
}
}

Future<NostrEvent> mostroUnWrap(NostrKeyPairs receiver) async {
Future<NostrEvent> mostroUnWrap(
NostrKeyPairs receiver, {
required String expectedAuthor,
}) async {
if (kind != 1059) {
throw ArgumentError('Expected kind 1059 (Gift Wrap), got: $kind');
}
Expand Down Expand Up @@ -139,6 +146,11 @@ extension NostrEventExtensions on NostrEvent {
throw Exception('SEAL content is empty');
}

// STEP 2b: Authenticate the sender before trusting anything inside.
// See NostrUtils.authenticateSeal for why the seal is the layer that
// carries this evidence.
NostrUtils.authenticateSeal(sealEvent, expectedAuthor);

// STEP 3: Decrypt SEAL with sender's pubkey (from SEAL)
// The SEAL pubkey identifies the actual sender (admin or user)
final senderPubkey = sealEvent.pubkey;
Expand Down
3 changes: 2 additions & 1 deletion lib/data/repositories/dispute_repository.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro_mobile/features/mostro/protocol_version_store.dart';
import 'package:mostro_mobile/data/models/dispute.dart';
import 'package:mostro_mobile/data/models/mostro_message.dart';
import 'package:mostro_mobile/data/models/enums/action.dart';
Expand Down Expand Up @@ -56,7 +57,7 @@ class DisputeRepository {
}
final mostroPow = mostroInstance?.pow ?? 0;
final event = await disputeMessage.wrapForTransport(
protocolVersion: mostroInstance?.protocolVersion,
protocolVersion: anchoredProtocolVersionFor(_ref),
tradeKey: session.tradeKey,
recipientPubKey: _mostroPubkey,
masterKey: session.fullPrivacy ? null : session.masterKey,
Expand Down
86 changes: 79 additions & 7 deletions lib/data/repositories/open_orders_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:mostro_mobile/data/repositories/order_repository_interface.dart'
import 'package:mostro_mobile/features/settings/settings.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/services/nostr_service.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';

const orderEventKind = 38383;
const infoEventKind = 38385;
Expand Down Expand Up @@ -66,14 +67,28 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {
final filterTime =
DateTime.now().subtract(Duration(hours: orderFilterDurationHours));

final filter = NostrFilter(
kinds: [orderEventKind, infoEventKind],
since: filterTime,
authors: [_settings.mostroPublicKey],
);

// Two filters, not one. The order history is deliberately bounded to a
// recent window, but the info event must not inherit that bound: kind
// 38385 is addressable, so the node publishes it once at startup and the
// relay keeps only that copy. A node that has been up longer than the
// window has an info event older than `since`, and a combined filter would
// make the relay withhold it — leaving `protocol_version` unknown for the
// whole session. That used to be harmless because unknown meant v1; now
// that unknown resolves to v2 it would strand the client on kind 14
// against a node that only listens on kind 1059.
final request = NostrRequest(
filters: [filter],
filters: [
NostrFilter(
kinds: [orderEventKind],
since: filterTime,
authors: [_settings.mostroPublicKey],
),
NostrFilter(
kinds: [infoEventKind],
authors: [_settings.mostroPublicKey],
limit: 1,
),
],
);

_subscription = _nostrService.subscribeToEvents(request).listen((event) {
Expand All @@ -82,6 +97,34 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {
_eventStreamController.add(_events.values.toList());
} else if (event.kind == infoEventKind &&
event.pubkey == _settings.mostroPublicKey) {
// The author field alone proves nothing: any relay can hand us an
// event that merely *claims* the node's pubkey. The info event
// configures the wire transport (`protocol_version`), the bond policy
// and the PoW target, so an unsigned forgery is a downgrade primitive.
if (!NostrUtils.isValidEventSignature(event)) {
logger.w(
'Rejecting kind-$infoEventKind info event claiming to be from '
'${event.pubkey}: signature verification failed',
);
return;
}
// A valid signature says the node authored this event, not that it
// still reflects the node's configuration. Relays pick which events
// they serve and in what order, so without a replacement rule the last
// one to arrive wins — letting a relay replay a genuinely signed but
// superseded info event to roll the advertised config back (most
// importantly `protocol_version` 2 -> 1).
//
// Reset to null on instance switch (see updateSettings), so this never
// blocks the newly selected node's own info event.
if (!_supersedesCurrentInfo(event)) {
logger.d(
'Ignoring kind-$infoEventKind info event ${event.id} from '
'${event.pubkey} dated ${event.createdAt}: does not supersede '
'${_mostroInstance?.id} dated ${_mostroInstance?.createdAt}',
);
return;
}
logger.i('Mostro instance info loaded: $event');
_mostroInstance = event;
if (!_mostroInstanceController.isClosed) {
Expand Down Expand Up @@ -118,6 +161,35 @@ class OpenOrdersRepository implements OrderRepository<NostrEvent> {
});
}

/// Whether [candidate] replaces the info event currently in use, under
/// NIP-01's ordering for addressable events: the higher `created_at` wins,
/// and a tie goes to the lower id.
///
/// The tie-break is what makes this converge. Two events sharing a second
/// are both genuinely the node's — they cleared the signature check — but
/// only one of them is the copy every other client will settle on, and a
/// pure "strictly newer" rule silently keeps whichever the fastest relay
/// happened to deliver. An exact re-delivery compares equal on both fields
/// and is still rejected, so this does not reopen the replay window the
/// check exists to close.
bool _supersedesCurrentInfo(NostrEvent candidate) {
final current = _mostroInstance;
if (current == null) return true;

final candidateAt = candidate.createdAt;
if (candidateAt == null) return false;
final currentAt = current.createdAt;
if (currentAt == null) return true;

if (candidateAt.isAfter(currentAt)) return true;
if (currentAt.isAfter(candidateAt)) return false;

final candidateId = candidate.id;
final currentId = current.id;
if (candidateId == null || currentId == null) return false;
return candidateId.compareTo(currentId) < 0;
}

void _emitEvents() {
if (!_eventStreamController.isClosed) {
_eventStreamController.add(_events.values.toList());
Expand Down
19 changes: 15 additions & 4 deletions lib/features/mostro/mostro_instance.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,26 @@ extension MostroInstanceExtensions on NostrEvent {

/// Parses the wire transport version from the `protocol_version` tag (§2).
///
/// Returns `null` when the tag is absent or unparseable. Callers treat
/// `null` as legacy v1 (NIP-59 gift wrap); the nullable form is preserved so
/// the transport resolver can distinguish "not advertised" from an explicit
/// version when deciding whether to log a version-skew downgrade.
/// Returns `null` when the tag is absent, empty or unparseable. Those are
/// not the same fact, and callers deciding a transport must not treat them
/// alike — pair this with [advertisesProtocolVersion] to tell them apart.
int? get protocolVersion {
final raw = _getOptionalTagValue('protocol_version');
return raw == null ? null : int.tryParse(raw);
}

/// Whether the event carries a `protocol_version` tag at all, regardless of
/// whether its value parses.
///
/// This is the half of the story [protocolVersion] cannot tell. A daemon
/// before v0.18.0 emits no tag, and on a verified event that silence *is* an
/// assertion of v1. A tag holding `""` or `abc` asserts nothing: the node
/// meant to state a version and the value is unusable, so the client has no
/// evidence and must fall back to its safe default rather than read the
/// malformed value as legacy and pair itself with gift wrap.
bool get advertisesProtocolVersion =>
tags?.any((t) => t.isNotEmpty && t[0] == 'protocol_version') ?? false;

/// Parses the anti-abuse bond policy from the `bond_enabled` tag.
///
/// - Tag absent → [BondPolicy.unsupported] (legacy daemon).
Expand Down
Loading
Loading