feat(#550): monotonic channel-send timestamp + CoreScope message fields

Unifies the previously-duplicated send timestamps (frame builder vs outgoing
message each called now() separately) into one monotonic-per-channel value, so
every channel send has a unique (ts, channel_idx) key. That is the client-side
guarantee the 0xC6 correlation needs (VioletBarn caught that AES-128-ECB
determinism would otherwise make two same-second messages a wrong-hash query).
Adds transient onAirHash + coreScopeObserverCount to ChannelMessage. Part of #524.

Agent: QuietSnow (session 31eaba02)
pull/556/head
Strycher 2 months ago
parent f2feb0863a
commit 63fc6a06c4

@ -17,6 +17,7 @@ import '../models/message.dart';
import '../models/offband_gps_status.dart';
import '../models/path_selection.dart';
import '../models/translation_support.dart';
import '../helpers/channel_send_timestamp.dart';
import '../helpers/pending_reactions.dart';
import '../helpers/pocketmesh_reaction.dart';
import '../helpers/reaction_helper.dart';
@ -235,6 +236,10 @@ class MeshCoreConnector extends ChangeNotifier {
final List<Channel> _channels = [];
final Map<String, List<Message>> _conversations = {};
final Map<int, List<ChannelMessage>> _channelMessages = {};
/// Last send timestamp (seconds) used per channel, to keep every channel
/// send's `(ts, channel_idx)` key unique for 0xC6 correlation (#524).
final Map<int, int> _lastChannelSendTsSecs = {};
final List<String> _pendingChannelSentQueue = [];
final List<_PendingCommandAck> _pendingGenericAckQueue = [];
static const String _reactionSendQueuePrefix = '__reaction_send__';
@ -3771,6 +3776,19 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// A strictly-increasing per-channel send timestamp (seconds), so no two
/// channel sends share a `(ts, channel_idx)` key. Required for 0xC6 hash
/// correlation (#524); harmless otherwise.
int _nextChannelSendTimestampSecs(int channelIndex) {
final nowSecs = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final ts = monotonicChannelSendTs(
nowSecs,
_lastChannelSendTsSecs[channelIndex],
);
_lastChannelSendTsSecs[channelIndex] = ts;
return ts;
}
Future<void> sendChannelMessage(
Channel channel,
String text, {
@ -3815,7 +3833,11 @@ class MeshCoreConnector extends ChangeNotifier {
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
try {
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, text),
buildSendChannelTextMsgFrame(
channel.index,
text,
timestamp: _nextChannelSendTimestampSecs(channel.index),
),
channelSendQueueId: reactionQueueId,
expectsGenericAck: true,
);
@ -3832,10 +3854,15 @@ class MeshCoreConnector extends ChangeNotifier {
return;
}
// One monotonic timestamp shared by the outgoing message and the frame, so
// the (ts, channel) key is unique and the client can correlate it to the
// firmware packet hash via 0xC6 (#524).
final sendTsSecs = _nextChannelSendTimestampSecs(channel.index);
final message = ChannelMessage.outgoing(
text,
_selfName ?? 'Me',
channel.index,
timestampSecs: sendTsSecs,
originalText: originalText,
translatedLanguageCode: translatedLanguageCode,
translationModelId: translationModelId,
@ -3848,7 +3875,11 @@ class MeshCoreConnector extends ChangeNotifier {
await _waitForRadioQuiet(lastInboundRxTime: _lastChannelMsgRxTime);
try {
await sendFrame(
buildSendChannelTextMsgFrame(channel.index, outboundText),
buildSendChannelTextMsgFrame(
channel.index,
outboundText,
timestamp: sendTsSecs,
),
channelSendQueueId: message.messageId,
expectsGenericAck: true,
);

@ -958,13 +958,19 @@ Uint8List buildSendTextMsgFrame(
// Build CMD_SEND_CHANNEL_TXT_MSG frame
// Format: [cmd][txt_type][channel_idx][timestamp x4][text...]
Uint8List buildSendChannelTextMsgFrame(int channelIndex, String text) {
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// [timestamp] (seconds) may be supplied by the caller so it can correlate the
// send to the firmware packet hash (0xC6, #524); defaults to now.
Uint8List buildSendChannelTextMsgFrame(
int channelIndex,
String text, {
int? timestamp,
}) {
final ts = timestamp ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
final writer = BufferWriter();
writer.writeByte(cmdSendChannelTxtMsg);
writer.writeByte(txtTypePlain);
writer.writeByte(channelIndex);
writer.writeUInt32LE(timestamp);
writer.writeUInt32LE(ts);
writer.writeString(text);
writer.writeByte(0);
return writer.toBytes();

@ -0,0 +1,17 @@
/// Returns a channel-send timestamp (Unix seconds) strictly greater than
/// [lastSecs] for the same channel, so no two sends on one channel ever share a
/// `(ts, channel_idx)` key.
///
/// This is a correctness requirement for the 0xC6 packet-hash correlation
/// (#524/#611): `msg_timestamp` is only second-resolution and MeshCore channel
/// encryption is deterministic (AES-128-ECB), so two different messages on the
/// same channel within one second would otherwise map to the same key but
/// different hashes, and the client would query CoreScope with the wrong hash.
///
/// Uses [nowSecs] unless it would collide with or precede [lastSecs], in which
/// case it bumps to `lastSecs + 1`. Mirrors the firmware's own
/// `getCurrentTimeUnique()`.
int monotonicChannelSendTs(int nowSecs, int? lastSecs) {
if (lastSecs != null && nowSecs <= lastSecs) return lastSecs + 1;
return nowSecs;
}

@ -46,6 +46,16 @@ class ChannelMessage {
final int? channelIndex;
final String messageId;
final String? packetHash;
/// Firmware-reported on-air packet hash for an outgoing channel message
/// (from the 0xC6 query, #524/#611). Distinct from [packetHash], which for
/// received messages is the content hash used for repeat dedup. Used only to
/// query CoreScope for observer counts. Transient (not persisted).
final String? onAirHash;
/// Unique observers CoreScope reports for this message's on-air packet
/// (#524). Null until queried / when the feature is off. Transient.
final int? coreScopeObserverCount;
final String? replyToMessageId;
final String? replyToSenderName;
final String? replyToText;
@ -84,6 +94,8 @@ class ChannelMessage {
this.channelIndex,
String? messageId,
this.packetHash,
this.onAirHash,
this.coreScopeObserverCount,
this.replyToMessageId,
this.replyToSenderName,
this.replyToText,
@ -130,6 +142,8 @@ class ChannelMessage {
Uint8List? pathBytes,
List<Uint8List>? pathVariants,
String? packetHash,
String? onAirHash,
int? coreScopeObserverCount,
String? replyToMessageId,
String? replyToSenderName,
String? replyToText,
@ -170,6 +184,9 @@ class ChannelMessage {
channelIndex: channelIndex,
messageId: messageId,
packetHash: packetHash ?? this.packetHash,
onAirHash: onAirHash ?? this.onAirHash,
coreScopeObserverCount:
coreScopeObserverCount ?? this.coreScopeObserverCount,
replyToMessageId: replyToMessageId ?? this.replyToMessageId,
replyToSenderName: replyToSenderName ?? this.replyToSenderName,
replyToText: replyToText ?? this.replyToText,
@ -261,6 +278,7 @@ class ChannelMessage {
String text,
String senderName,
int channelIndex, {
int? timestampSecs,
String? originalText,
String? translatedLanguageCode,
String? translationModelId,
@ -272,7 +290,11 @@ class ChannelMessage {
originalText: originalText,
translatedLanguageCode: translatedLanguageCode,
translationModelId: translationModelId,
timestamp: DateTime.now(),
// When supplied, match the exact seconds put in the send frame so the
// 0xC6 (ts, channel) key correlates (#524); else fall back to now.
timestamp: timestampSecs != null
? DateTime.fromMillisecondsSinceEpoch(timestampSecs * 1000)
: DateTime.now(),
isOutgoing: true,
status: ChannelMessageStatus.pending,
pathLength: null,

@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/channel_send_timestamp.dart';
void main() {
group('monotonicChannelSendTs', () {
test('first send on a channel uses now', () {
expect(monotonicChannelSendTs(1000, null), 1000);
});
test('bumps by 1 on a same-second collision', () {
expect(monotonicChannelSendTs(1000, 1000), 1001);
});
test('uses now when it has advanced past the last send', () {
expect(monotonicChannelSendTs(1005, 1000), 1005);
});
test('bumps past the last send if the clock went backwards', () {
expect(monotonicChannelSendTs(1000, 1005), 1006);
});
test('a rapid burst stays strictly increasing', () {
int? last;
final produced = <int>[];
// Same wall-clock second for the whole burst.
for (var i = 0; i < 5; i++) {
last = monotonicChannelSendTs(1000, last);
produced.add(last);
}
expect(produced, [1000, 1001, 1002, 1003, 1004]);
expect(produced.toSet().length, produced.length); // all unique
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.