Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -7,7 +7,8 @@ enum SharedPreferencesKeys {
trustedNodeMetadata('trusted_node_metadata'),
backgroundFilters('background_filters'),
communitySelected('community_selected'),
nodeProtocolVersions('node_protocol_versions');
nodeProtocolVersions('node_protocol_versions'),
orderFreshness('order_freshness');

final String value;

Expand Down
12 changes: 9 additions & 3 deletions lib/data/models/last_trade_index_response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ class LastTradeIndexResponse implements Payload {
String get type => 'last-trade-index';

factory LastTradeIndexResponse.fromJson(Map<String, dynamic> json) {
return LastTradeIndexResponse(
tradeIndex: json['trade_index'] as int,
);
final raw = json['trade_index'];
final tradeIndex = raw is int ? raw : (raw is num ? raw.toInt() : null);
if (tradeIndex == null || tradeIndex < 0) {
// A trade index is a count of keys derived; negative or non-numeric is
// not a value the daemon can mean, and it feeds a key-derivation
// counter, so it is refused here rather than clamped downstream.
throw FormatException('Invalid trade_index: $raw');
}
return LastTradeIndexResponse(tradeIndex: tradeIndex);
}

@override
Expand Down
23 changes: 22 additions & 1 deletion lib/data/models/mostro_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ class MostroMessage<T extends Payload> {
final Action action;
int? tradeIndex;
T? _payload;

/// Milliseconds since the Unix epoch.
///
/// One unit for this field, everywhere. It is read back from two sources
/// that do not agree natively — the local store writes milliseconds, while
/// anything arriving over the wire follows the Nostr convention of seconds —
/// and mixing them silently corrupted dispute ordering in both directions
/// (see [_toMillis]). Every producer normalises here rather than at each
/// consumer.
int? timestamp;

MostroMessage({
Expand Down Expand Up @@ -45,8 +54,20 @@ class MostroMessage<T extends Payload> {
return json;
}

/// Normalises an epoch value to milliseconds.
///
/// `fromJson` deserialises both the local store (milliseconds) and wire
/// payloads (seconds), so the unit has to be inferred. The threshold is not
/// a guess: 1e12 milliseconds is 2001-09-09, and 1e12 seconds is far beyond
/// any date this app will see, so no real timestamp is ambiguous.
static int? _toMillis(dynamic raw) {
final value = raw is int ? raw : (raw is num ? raw.toInt() : null);
if (value == null) return null;
return value.abs() < 1000000000000 ? value * 1000 : value;
}

factory MostroMessage.fromJson(Map<String, dynamic> json) {
final timestamp = json['timestamp'];
final timestamp = _toMillis(json['timestamp']);
// IMPORTANT : Use 'order', 'restore' or 'cant-do' key as per protocol
json = json['order'] ?? json['restore'] ?? json['cant-do'] ?? json;
final num requestId = json['request_id'] ?? 0;
Expand Down
3 changes: 3 additions & 0 deletions lib/data/models/order.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class Order implements Payload {
final String? buyerTradePubkey;
final String? sellerTradePubkey;
final String? buyerInvoice;
/// Seconds since the Unix epoch, as the protocol sends it (Nostr
/// convention). Convert before handing it to anything that expects
/// milliseconds, including [MostroMessage.timestamp].
final int? createdAt;
final int? expiresAt;

Expand Down
5 changes: 5 additions & 0 deletions lib/data/repositories/mostro_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class MostroStorage extends BaseStorage<MostroMessage> {
if (await hasItem(id)) return;
// Add metadata for easier querying
final Map<String, dynamic> dbMap = message.toJson();
// Receive time, and only as a last resort. This is not evidence of when
// the node spoke — a relay chooses when to deliver — so it is a
// placeholder for messages that carry no signed clock (the v1 gift-wrap
// path, and locally synthesised messages). v2 messages arrive with
// `timestamp` already set from the node-signed `created_at`.
message.timestamp ??= DateTime.now().millisecondsSinceEpoch;
dbMap['timestamp'] = message.timestamp;

Expand Down
11 changes: 11 additions & 0 deletions lib/features/chat/notifiers/chat_room_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:mostro_mobile/data/models/chat_room.dart';
import 'package:mostro_mobile/data/models/nostr_event.dart';
import 'package:mostro_mobile/data/models/session.dart';
import 'package:mostro_mobile/services/chat_cursor_store.dart';
import 'package:mostro_mobile/shared/utils/in_flight_events.dart';
import 'package:mostro_mobile/services/encrypted_image_upload_service.dart';
import 'package:mostro_mobile/services/encrypted_file_upload_service.dart';
import 'package:sembast/sembast.dart';
Expand All @@ -27,6 +28,10 @@ import 'package:mostro_mobile/shared/utils/chat_keys.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';

class ChatRoomNotifier extends StateNotifier<ChatRoom> with MediaCacheMixin {
/// Guards against concurrent re-delivery now that the durable write happens
/// after the envelope is authenticated. See [InFlightEvents].
final InFlightEvents _inFlight = InFlightEvents();

static final EncryptedImageUploadService _imageUploadService =
EncryptedImageUploadService();
static final EncryptedFileUploadService _fileUploadService =
Expand Down Expand Up @@ -140,6 +145,12 @@ class ChatRoomNotifier extends StateNotifier<ChatRoom> with MediaCacheMixin {
Future<void> handleChatEvent(NostrEvent event) => _onChatEvent(event);

Future<void> _onChatEvent(NostrEvent event) async {
final eventId = event.id;
if (eventId == null) return;
await _inFlight.guard(eventId, () => _processChatEvent(event));
}

Future<void> _processChatEvent(NostrEvent event) async {
try {
if (event.kind != 14) {
return;
Expand Down
19 changes: 15 additions & 4 deletions lib/features/disputes/notifiers/dispute_chat_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:dart_nostr/dart_nostr.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/data/models/nostr_event.dart';
import 'package:mostro_mobile/shared/utils/in_flight_events.dart';
import 'package:mostro_mobile/data/models/session.dart';
import 'package:mostro_mobile/features/chat/providers/active_chat_screens_provider.dart';
import 'package:mostro_mobile/features/notifications/providers/notifications_provider.dart';
Expand Down Expand Up @@ -80,6 +81,10 @@ class DisputeChatState {
/// derived from the admin ECDH shared secret.
/// Stores the encrypted outer events on disk, same pattern as P2P chat.
class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCacheMixin {
/// Guards against concurrent re-delivery now that the durable write happens
/// after the envelope is authenticated. See [InFlightEvents].
final InFlightEvents _inFlight = InFlightEvents();

static final EncryptedImageUploadService _imageUploadService =
EncryptedImageUploadService();
static final EncryptedFileUploadService _fileUploadService =
Expand Down Expand Up @@ -202,7 +207,16 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach

/// Handle incoming chat events via chatUnwrap.
/// Stores the outer event (encrypted) to disk, then unwraps for display.
void _onChatEvent(NostrEvent event) async {
Future<void> _onChatEvent(NostrEvent event) async {
final eventId = event.id;
if (eventId == null) return;
await _inFlight.guard(eventId, () => _processChatEvent(event, eventId));
}

Future<void> _processChatEvent(
NostrEvent event,
String wrapperEventId,
) async {
try {
if (!mounted || event.kind != 14) return;

Expand All @@ -213,9 +227,6 @@ class DisputeChatNotifier extends StateNotifier<DisputeChatState> with MediaCach
final chatKeys = _getChatKeys(session);
if (event.pubkey != chatKeys.sign.public) return;

// Check for duplicate outer events (relay re-deliveries)
final wrapperEventId = event.id;
if (wrapperEventId == null) return;
// Already on disk means a relay re-delivery, an own echo, or an event
// the background service stored while the app slept. Keep processing:
// state is keyed by inner id, so only the write is redundant.
Expand Down
34 changes: 34 additions & 0 deletions lib/features/key_manager/key_manager.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:dart_nostr/dart_nostr.dart';
import 'package:mostro_mobile/features/key_manager/key_derivator.dart';
import 'package:mostro_mobile/features/key_manager/key_storage.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/features/key_manager/key_manager_errors.dart';

class KeyManager {
Expand Down Expand Up @@ -127,4 +128,37 @@ class KeyManager {
tradeKeyIndex = index;
await _storage.storeTradeKeyIndex(index);
}

/// Moves the trade key index up to [index], and never down.
///
/// Use this for any index derived from a value the daemon sent. The counter
/// records how many trade keys this device has already derived, so lowering
/// it hands the next trade a keypair that has been used before: past and
/// future trades become linkable by a shared pubkey, and a live session can
/// find its key reissued underneath it.
///
/// Refusing to go down is also simply correct, attack or no attack. The
/// daemon only knows the indexes that reached an order, so a device that
/// derived keys without trading legitimately sits ahead of it, and a restore
/// must not undo that.
///
/// Returns the index in effect afterwards.
Future<int> raiseCurrentKeyIndexTo(int index) async {
if (index < 1) {
throw InvalidTradeKeyIndexException(
'Trade key index must be greater than 0',
);
}

final current = await getCurrentKeyIndex();
if (index <= current) {
logger.w(
'Refusing to lower trade key index from $current to $index',
);
return current;
}

await setCurrentKeyIndex(index);
return index;
}
}
25 changes: 21 additions & 4 deletions lib/features/mostro/mostro_nodes_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:mostro_mobile/data/models/enums/storage_keys.dart';
import 'package:mostro_mobile/features/mostro/mostro_node.dart';
import 'package:mostro_mobile/features/settings/settings_provider.dart';
import 'package:mostro_mobile/services/logger_service.dart';
import 'package:mostro_mobile/shared/utils/nostr_utils.dart';
import 'package:mostro_mobile/shared/providers/nostr_service_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

Expand Down Expand Up @@ -269,12 +270,28 @@ class MostroNodesNotifier extends StateNotifier<List<MostroNode>> {

void _applyMetadataFromEvent(NostrEvent event) {
try {
if (!event.isVerified()) {
// Fail closed. The previous rule applied metadata even when
// verification failed, reasoning that the author filter had already
// vouched for the event — but a filter is a request, not a guarantee:
// the relay decides what to answer with, and nothing stops it from
// returning an event it wrote itself. This metadata is the name and
// avatar the user reads when choosing which node to trade against, so
// an unverified one is a free impersonation of a trusted node.
//
// `NostrUtils.isValidEventSignature` rather than `isVerified()`: the
// latter checks the signature against the event's self-declared id and
// never recomputes it, so a genuine (id, sig, pubkey) triple lifted onto
// attacker-chosen content still passes.
if (!NostrUtils.isValidEventSignature(event)) {
logger.w(
'Kind 0 event for ${event.pubkey} failed signature verification '
'(may be a dart_nostr limitation). Applying metadata anyway since '
'the event was fetched by author filter.',
'Rejecting kind 0 metadata claiming to be from ${event.pubkey}: '
'signature verification failed',
);
return;
}
if (event.kind != 0) {
logger.w('Ignoring non-kind-0 event as node metadata: ${event.kind}');
return;
}
final json = jsonDecode(event.content ?? '') as Map<String, dynamic>;
updateNodeMetadata(
Expand Down
9 changes: 6 additions & 3 deletions lib/features/order/models/order_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -192,10 +192,13 @@ class OrderState {
// If we got a dispute from the message payload, ensure it has the message timestamp
// This is critical for correct sorting in the dispute list
if (updatedDispute != null && message.getPayload<Dispute>() != null) {
// Use message timestamp if dispute doesn't have a createdAt or if message has a timestamp
// Note: Nostr timestamps are in seconds, so convert to milliseconds
// MostroMessage.timestamp is already milliseconds (normalised at the
// model boundary). It used to be multiplied by 1000 here under a comment
// claiming it was seconds, which pushed every live dispute's createdAt
// tens of thousands of years into the future and pinned it to the top of
// the dispute list for good.
if (message.timestamp != null) {
final tsMs = message.timestamp! * 1000;
final tsMs = message.timestamp!;
if (updatedDispute.createdAt == null ||
updatedDispute.createdAt!.millisecondsSinceEpoch != tsMs) {
updatedDispute = updatedDispute.copyWith(
Expand Down
91 changes: 91 additions & 0 deletions lib/features/order/notifiers/abstract_mostro_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro_mobile/data/enums.dart';
import 'package:mostro_mobile/data/models.dart';
import 'package:mostro_mobile/features/mostro/mostro_instance.dart';
import 'package:mostro_mobile/features/order/order_freshness_store.dart';
import 'package:mostro_mobile/features/order/models/order_state.dart';
import 'package:mostro_mobile/features/restore/restore_mode_provider.dart';
import 'package:mostro_mobile/shared/providers.dart';
Expand All @@ -24,6 +25,80 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
late Session session;

ProviderSubscription<AsyncValue<MostroMessage?>>? subscription;

/// Signed timestamp of the newest message already folded into [state].
///
/// The high-water mark MM-021 calls for. Every message reaching this notifier
/// is authentic — a replayed one carries the node's real signature over its
/// real, old timestamp — so authentication alone cannot tell a fresh
/// instruction from an archived one. Ordering can: a message older than the
/// state it would modify is describing a past this order has already left.
///
/// Rebuilt from storage on [sync] and mirrored into [OrderFreshnessStore],
/// which outlives the databases a restore clears. Null until the first
/// timestamped message arrives and nothing is remembered.
int? _lastAppliedTimestamp;

@protected
int? get lastAppliedTimestamp {
final local = _lastAppliedTimestamp;
final remembered = _rememberedTimestamp();
if (local == null) return remembered;
if (remembered == null) return local;
return local > remembered ? local : remembered;
}

@protected
set lastAppliedTimestamp(int? value) {
_lastAppliedTimestamp = value;
if (value != null) {
try {
ref.read(orderFreshnessStoreProvider).record(orderId, value);
} catch (e) {
// Losing the durable mirror costs this order's memory across a
// restore; it can never produce a wrong (lower) mark, because the
// store only moves forward.
logger.w('Failed to persist freshness for order $orderId: $e');
}
}
}

int? _rememberedTimestamp() {
try {
return ref.read(orderFreshnessStoreProvider).timestampFor(orderId);
} catch (e) {
return null;
}
}

/// Whether [msg] may modify the current state.
///
/// Fails open on a missing timestamp: the v1 gift-wrap path has no signed
/// clock (NIP-59 randomises those timestamps by design), so refusing
/// untimestamped messages would break v1 entirely rather than protect it.
/// Equal timestamps pass — a node can legitimately emit several messages in
/// one second, and exact re-deliveries are already stopped by event-id dedup.
@protected
bool supersedesAppliedState(MostroMessage msg) {
final incoming = msg.timestamp;
final applied = lastAppliedTimestamp;
if (incoming == null || applied == null) return true;
return incoming >= applied;
}

/// Moves the freshness mark forward to [appliedAt], never backwards.
///
/// For state applied from something other than a streamed message — a
/// restored snapshot, whose own message is dated with the order's creation
/// time rather than the moment the state it carries describes.
@protected
void anchorAppliedTimestamp(int? appliedAt) {
if (appliedAt == null) return;
final applied = lastAppliedTimestamp;
if (applied == null || appliedAt > applied) {
lastAppliedTimestamp = appliedAt;
}
}
final Set<String> _processedEventIds = <String>{};

// Timer storage for orphan session cleanup
Expand Down Expand Up @@ -93,6 +168,19 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {
logger.i('Received message with action: ${msg?.action}');
}
if (msg != null) {
// Freshness first, before anything is consumed. Everything below
// mutates state a stale message must not be able to spend: the
// orphan session timer, and the user-initiated cancel marker — a
// superseded `canceled` that took that marker would leave a
// genuine cancel in flight to be read as a counterparty timeout.
if (!supersedesAppliedState(msg)) {
logger.w(
'Ignoring stale ${msg.action} for order $orderId: dated '
'${msg.timestamp}, state already at $lastAppliedTimestamp',
);
return;
}

// Cancel timer on ANY response from Mostro for this order
cancelSessionTimeoutCleanup(orderId);

Expand All @@ -109,6 +197,9 @@ class AbstractMostroNotifier extends StateNotifier<OrderState> {

if (mounted) {
state = state.updateWith(msg);
if (msg.timestamp != null) {
lastAppliedTimestamp = msg.timestamp;
}
}
if (msg.timestamp != null &&
msg.timestamp! >
Expand Down
Loading
Loading