feat(#578): connector identity export/import + auto-add hop limit

Prerequisite for the export/import epic (#568): two device values the
connector could not read or write, both already exposed by firmware.

Identity (CMD_EXPORT_PRIVATE_KEY 23 / CMD_IMPORT_PRIVATE_KEY 24):
- adds the commands, RESP_CODE_PRIVATE_KEY 14 and the previously unhandled
  RESP_CODE_DISABLED 15
- both firmware commands sit behind build flags, so a radio can legitimately
  answer DISABLED. IdentityTransfer keeps unsupported distinct from rejected
  so the UI can say the radio cannot do it rather than offering a retry that
  can never succeed
- a short PRIVATE_KEY frame reports rejected instead of handing back a
  truncated key that could be written to an export file
- import is destructive and documented as such; the key is never logged

Auto-add hop limit:
- GET already returned max_hops as a third byte and we discarded it; it is
  now parsed and exposed
- SET learns an optional maxHops. Omitting it keeps the frame two bytes, so
  firmware leaves the device value alone and existing callers are unchanged
- clamped to 64 on our side to match the firmware clamp

Adds handleFrameForTest so parse paths can be exercised without a radio,
following the file's existing visibleForTesting convention. 12 new tests.
epic/568-export-import
Strycher 2 months ago
parent ae95112b3d
commit 19db2ffd1e

@ -181,6 +181,38 @@ enum MeshCoreConnectionState {
enum MeshCoreTransportType { bluetooth, usb, tcp }
/// Outcome of a node-identity read or write. (#578)
///
/// [unsupported] is deliberately distinct from [rejected]: the firmware can be
/// built without identity transfer at all (`ENABLE_PRIVATE_KEY_EXPORT` /
/// `ENABLE_PRIVATE_KEY_IMPORT`), and a UI must say "this radio cannot do it"
/// rather than "that failed, try again".
enum IdentityTransfer {
/// The device returned its identity, or accepted the one we sent.
ok,
/// The firmware was compiled without the feature.
unsupported,
/// The device understood the request and refused it, e.g. an invalid key.
rejected,
/// Not connected, or no reply before the timeout.
noReply,
}
/// Result of [MeshCoreConnector.exportPrivateKey]. [identity] is
/// non-null only when [outcome] is [IdentityTransfer.ok]. (#578)
class IdentityExportResult {
const IdentityExportResult(this.outcome, [this.identity]);
final IdentityTransfer outcome;
/// The 64-byte node identity. Secret material: never log it, never persist
/// it outside a user-initiated export file.
final Uint8List? identity;
}
class RepeaterBatterySnapshot {
final int millivolts;
final DateTime updatedAt;
@ -297,6 +329,9 @@ class MeshCoreConnector extends ChangeNotifier {
bool _caplogAwaitingStart = false;
Completer<CaplogAck>? _caplogAckCompleter;
Completer<CaplogDeviceStatus>? _caplogStatusCompleter;
// Node identity transfer (#578). Only one of each may be in flight.
Completer<IdentityExportResult>? _identityExportCompleter;
Completer<IdentityTransfer>? _identityImportCompleter;
/// Outstanding 0xC6 packet-hash queries, keyed "ts_chan" so a reply matches
/// its request without relying on ordering (#524).
@ -352,6 +387,7 @@ class MeshCoreConnector extends ChangeNotifier {
bool _bleInitialSyncStarted = false;
bool _webInitialHandshakeRequestSent = false;
bool _preserveContactsOnRefresh = false;
int _autoAddMaxHops = 0;
bool _autoAddUsers = false;
bool _autoAddRepeaters = false;
bool _autoAddRoomServers = false;
@ -594,6 +630,10 @@ class MeshCoreConnector extends ChangeNotifier {
bool? get autoAddRoomServers => _autoAddRoomServers;
bool? get autoAddSensors => _autoAddSensors;
bool? get autoAddOverwriteOldest => _overwriteOldest;
/// `autoadd_max_hops`; 0 means no limit. Stays 0 on firmware old enough to
/// answer [respCodeAutoAddConfig] with only two bytes. (#578)
int get autoAddMaxHops => _autoAddMaxHops;
int get telemetryModeBase => _telemetryModeBase;
int get telemetryModeLoc => _telemetryModeLoc;
int get telemetryModeEnv => _telemetryModeEnv;
@ -2927,6 +2967,11 @@ class MeshCoreConnector extends ChangeNotifier {
void resetConnectionHandshakeStateForTest() =>
_resetConnectionHandshakeState();
/// Feeds a device frame through the normal dispatch, so parse paths can be
/// exercised without a radio. (#578)
@visibleForTesting
void handleFrameForTest(List<int> frame) => _handleFrameInner(frame);
@visibleForTesting
Map<int, List<ChannelMessage>> get channelMessagesForTest => _channelMessages;
@ -4581,6 +4626,129 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
/// Reads the node's 64-byte identity.
///
/// Firmware can be built without this (`ENABLE_PRIVATE_KEY_EXPORT`), in which
/// case the device answers [respCodeDisabled] and the result is
/// [IdentityTransfer.unsupported] rather than a failure. Callers must handle
/// that case: it means this radio can never do it, so retrying is pointless
/// and an export must proceed without the identity section. (#578)
Future<IdentityExportResult> exportPrivateKey({
Duration timeout = const Duration(seconds: 5),
}) async {
if (!isConnected) {
return const IdentityExportResult(IdentityTransfer.noReply);
}
final existing = _identityExportCompleter;
if (existing != null && !existing.isCompleted) {
return existing.future.timeout(
timeout,
onTimeout: () => const IdentityExportResult(IdentityTransfer.noReply),
);
}
final completer = Completer<IdentityExportResult>();
_identityExportCompleter = completer;
await sendFrame(buildExportPrivateKeyFrame());
try {
return await completer.future.timeout(timeout);
} on TimeoutException {
return const IdentityExportResult(IdentityTransfer.noReply);
} finally {
if (identical(_identityExportCompleter, completer)) {
_identityExportCompleter = null;
}
}
}
/// Overwrites the node's identity with [identity], which must be
/// [privateKeySize] bytes.
///
/// **This is destructive.** The node's existing identity is replaced, every
/// contact's view of this node becomes stale, and there is no undo short of
/// importing the previous key back. Callers must confirm with the user first.
/// Availability is firmware-dependent exactly as in [exportPrivateKey]. (#578)
Future<IdentityTransfer> importPrivateKey(
Uint8List identity, {
Duration timeout = const Duration(seconds: 5),
}) async {
if (identity.length != privateKeySize) {
throw ArgumentError.value(
identity.length,
'identity',
'a node identity is exactly $privateKeySize bytes',
);
}
if (!isConnected) return IdentityTransfer.noReply;
final completer = Completer<IdentityTransfer>();
_identityImportCompleter = completer;
await sendFrame(buildImportPrivateKeyFrame(identity));
try {
return await completer.future.timeout(timeout);
} on TimeoutException {
return IdentityTransfer.noReply;
} finally {
if (identical(_identityImportCompleter, completer)) {
_identityImportCompleter = null;
}
}
}
void _handlePrivateKey(Uint8List frame) {
final completer = _identityExportCompleter;
_identityExportCompleter = null;
if (completer == null || completer.isCompleted) return;
if (frame.length < 1 + privateKeySize) {
// A short frame is not a usable identity; report it as a refusal rather
// than handing a caller a truncated key it might write to a file.
appLogger.error(
'RESP_CODE_PRIVATE_KEY frame too short: ${frame.length} bytes',
tag: 'Connector',
);
completer.complete(const IdentityExportResult(IdentityTransfer.rejected));
return;
}
// Never log the key itself.
completer.complete(
IdentityExportResult(
IdentityTransfer.ok,
Uint8List.sublistView(frame, 1, 1 + privateKeySize),
),
);
}
/// RESP_CODE_DISABLED is generic: it means "that feature is not in this
/// build". Only an identity request is currently able to provoke it from us,
/// so it resolves whichever identity transfer is in flight and is otherwise
/// logged rather than silently dropped. (#578)
void _handleDisabledFrame() {
final export = _identityExportCompleter;
_identityExportCompleter = null;
if (export != null && !export.isCompleted) {
export.complete(const IdentityExportResult(IdentityTransfer.unsupported));
return;
}
final import = _identityImportCompleter;
_identityImportCompleter = null;
if (import != null && !import.isCompleted) {
import.complete(IdentityTransfer.unsupported);
return;
}
appLogger.warn(
'Device reported a disabled feature with no request in flight',
tag: 'Connector',
);
}
/// Lets an in-flight identity import claim a generic OK or ERR frame.
/// Returns true when it did, so the shared handlers can stop. (#578)
bool _completeIdentityImport(IdentityTransfer outcome) {
final completer = _identityImportCompleter;
if (completer == null || completer.isCompleted) return false;
_identityImportCompleter = null;
completer.complete(outcome);
return true;
}
void _handleOffbandGps(Uint8List frame) {
if (frame.length < 2) return;
final text = utf8.decode(frame.sublist(1), allowMalformed: true);
@ -5129,8 +5297,17 @@ class MeshCoreConnector extends ChangeNotifier {
switch (code) {
case respCodeOk:
// An identity import is confirmed by a generic OK, so it claims the
// frame before the shared handler runs. (#578)
if (_completeIdentityImport(IdentityTransfer.ok)) break;
_handleOk();
break;
case respCodePrivateKey:
_handlePrivateKey(frame);
break;
case respCodeDisabled:
_handleDisabledFrame();
break;
case respCodeDeviceInfo:
_handleDeviceInfo(frame);
break;
@ -5304,6 +5481,9 @@ class MeshCoreConnector extends ChangeNotifier {
}) => isSyncingChannels && channelSyncInFlight && !hasPendingGenericAck;
void _handleErrorFrame(Uint8List frame) {
// An identity import is refused with a generic ERR (illegal argument for a
// malformed key), so it claims the frame first. (#578)
if (_completeIdentityImport(IdentityTransfer.rejected)) return;
// A caplog download awaiting its START frame: the firmware answers the
// generic RESP_CODE_ERR when another stream (block-list / contacts /
// observer config) is already in flight. Fail the download fast with a
@ -8501,6 +8681,11 @@ class MeshCoreConnector extends ChangeNotifier {
_autoAddRoomServers = (flags & autoAddRoomServerFlag) != 0;
_autoAddSensors = (flags & autoAddSensorFlag) != 0;
_overwriteOldest = (flags & autoAddOverwriteOldestFlag) != 0;
// The hop limit is the third byte. Older firmware sends a 2-byte frame,
// so its absence is normal and leaves the previous value alone. (#578)
if (frame.length > 2) {
_autoAddMaxHops = reader.readByte();
}
} catch (e) {
appLogger.error('Failed to parse auto-add config: $e', tag: 'Connector');
}

@ -213,6 +213,17 @@ const int cmdSetAutoAddConfig = 58;
const int cmdGetAutoAddConfig = 59;
const int cmdSetPathHashMode = 61;
/// Read the node's 64-byte identity. Compiled out of some firmware builds
/// (`ENABLE_PRIVATE_KEY_EXPORT`), in which case the device answers
/// [respCodeDisabled] rather than [respCodePrivateKey]. (#578)
const int cmdExportPrivateKey = 23;
/// Overwrite the node's identity with a 64-byte key. Like the export command
/// this can be compiled out (`ENABLE_PRIVATE_KEY_IMPORT`). Firmware validates
/// the key and answers [respCodeErr] with [errCodeIllegalArg] if it is
/// malformed. (#578)
const int cmdImportPrivateKey = 24;
// Text message types
const int txtTypePlain = 0;
const int txtTypeCliData = 1;
@ -256,6 +267,18 @@ const int respCodeCustomVars = 21;
const int respCodeAutoAddConfig = 25;
const int respCodeStats = 24;
/// Reply to [cmdExportPrivateKey]: this byte followed by
/// [privateKeySize] bytes. (#578)
const int respCodePrivateKey = 14;
/// One-byte reply meaning the firmware was built without the requested
/// feature. Distinct from [respCodeErr], which reports a runtime failure of a
/// command the firmware does support. (#578)
const int respCodeDisabled = 15;
/// A node identity is 64 bytes: the Ed25519 secret followed by its public key.
const int privateKeySize = 64;
/// Offband fork-only extension space (0xC0+), never collides with upstream,
/// never submitted upstream. Request and reply share the code. (#135)
const int cmdOffbandGps = 0xC1;
@ -664,6 +687,9 @@ const int payloadTypeRawCustom =
0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
//auto-add flags
/// Firmware clamps `autoadd_max_hops` to this value. 0 means no limit. (#578)
const int autoAddMaxHopsLimit = 64;
const int autoAddOverwriteOldestFlag =
1 << 0; // 0x01 - overwrite oldest non-favourite when full
const int autoAddChatFlag =
@ -1408,6 +1434,7 @@ Uint8List buildSetAutoAddConfigFrame({
required bool autoAddRoomServer,
required bool autoAddSensor,
required bool overwriteOldest,
int? maxHops,
}) {
final writer = BufferWriter();
writer.writeByte(cmdSetAutoAddConfig);
@ -1418,6 +1445,32 @@ Uint8List buildSetAutoAddConfigFrame({
if (autoAddSensor) flags |= autoAddSensorFlag;
if (overwriteOldest) flags |= autoAddOverwriteOldestFlag;
writer.writeByte(flags);
// Firmware applies the hop limit only when the frame carries a third byte,
// so omitting [maxHops] leaves the device's current limit untouched. It
// clamps to [autoAddMaxHopsLimit] on its side; we clamp here too so the
// value we believe we sent is the value that lands. (#578)
if (maxHops != null) {
writer.writeByte(maxHops.clamp(0, autoAddMaxHopsLimit));
}
return writer.toBytes();
}
/// Request the node's identity. See [cmdExportPrivateKey] for availability.
Uint8List buildExportPrivateKeyFrame() =>
Uint8List.fromList([cmdExportPrivateKey]);
/// Overwrite the node's identity. [identity] must be [privateKeySize] bytes.
Uint8List buildImportPrivateKeyFrame(Uint8List identity) {
if (identity.length != privateKeySize) {
throw ArgumentError.value(
identity.length,
'identity',
'a node identity is exactly $privateKeySize bytes',
);
}
final writer = BufferWriter();
writer.writeByte(cmdImportPrivateKey);
writer.writeBytes(identity);
return writer.toBytes();
}

@ -0,0 +1,165 @@
// #578 (epic #568): node identity export/import and the auto-add hop limit.
//
// The identity commands are behind firmware build flags, so a device can
// legitimately answer RESP_CODE_DISABLED. These tests pin that "this radio was
// built without the feature" stays distinguishable from "that request failed",
// because the export UI has to degrade rather than offer a retry that can never
// succeed.
//
// No key material here is real.
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/storage/prefs_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
Uint8List fakeIdentity([int fill = 0x11]) =>
Uint8List.fromList(List<int>.filled(privateKeySize, fill));
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('frame construction', () {
test('the export request is a bare one-byte command', () {
expect(buildExportPrivateKeyFrame(), [cmdExportPrivateKey]);
});
test('the import request carries the command then 64 key bytes', () {
final frame = buildImportPrivateKeyFrame(fakeIdentity(0xAB));
// Firmware requires len >= 65 before it will even parse the command.
expect(frame, hasLength(1 + privateKeySize));
expect(frame[0], cmdImportPrivateKey);
expect(frame.sublist(1), everyElement(0xAB));
});
test(
'an identity of the wrong size is refused before it reaches the wire',
() {
expect(
() => buildImportPrivateKeyFrame(Uint8List(32)),
throwsA(isA<ArgumentError>()),
);
},
);
});
group('auto-add hop limit', () {
test(
'omitting the limit keeps the frame two bytes, as firmware expects',
() {
// Firmware applies the third byte only when present, so a two-byte frame
// leaves the device's existing limit alone. Older behavior must not
// change just because the parameter now exists.
final frame = buildSetAutoAddConfigFrame(
autoAddChat: true,
autoAddRepeater: false,
autoAddRoomServer: false,
autoAddSensor: false,
overwriteOldest: false,
);
expect(frame, hasLength(2));
expect(frame[0], cmdSetAutoAddConfig);
expect(frame[1], autoAddChatFlag);
},
);
test('supplying the limit appends it as a third byte', () {
final frame = buildSetAutoAddConfigFrame(
autoAddChat: false,
autoAddRepeater: false,
autoAddRoomServer: false,
autoAddSensor: false,
overwriteOldest: true,
maxHops: 5,
);
expect(frame, hasLength(3));
expect(frame[1], autoAddOverwriteOldestFlag);
expect(frame[2], 5);
});
test('the limit is clamped to what firmware will accept', () {
final frame = buildSetAutoAddConfigFrame(
autoAddChat: false,
autoAddRepeater: false,
autoAddRoomServer: false,
autoAddSensor: false,
overwriteOldest: false,
maxHops: 250,
);
expect(frame[2], autoAddMaxHopsLimit);
});
});
group('connector', () {
late MeshCoreConnector connector;
setUp(() async {
SharedPreferences.setMockInitialValues({});
PrefsManager.reset();
await PrefsManager.initialize();
connector = MeshCoreConnector();
});
test(
'exporting while disconnected reports no reply, not a refusal',
() async {
final result = await connector.exportPrivateKey();
expect(result.outcome, IdentityTransfer.noReply);
expect(result.identity, isNull);
},
);
test('importing while disconnected reports no reply', () async {
expect(
await connector.importPrivateKey(fakeIdentity()),
IdentityTransfer.noReply,
);
});
test('importing a wrongly sized identity throws before any I/O', () async {
expect(
() => connector.importPrivateKey(Uint8List(10)),
throwsA(isA<ArgumentError>()),
);
});
test('the hop limit defaults to no limit until a device reports one', () {
expect(connector.autoAddMaxHops, 0);
});
test('a three-byte auto-add reply sets the hop limit', () {
connector.handleFrameForTest([
respCodeAutoAddConfig,
autoAddChatFlag | autoAddOverwriteOldestFlag,
7,
]);
expect(connector.autoAddMaxHops, 7);
expect(connector.autoAddUsers, isTrue);
expect(connector.autoAddOverwriteOldest, isTrue);
expect(connector.autoAddRepeaters, isFalse);
});
test(
'a two-byte auto-add reply from older firmware leaves the limit alone',
() {
connector.handleFrameForTest([
respCodeAutoAddConfig,
autoAddChatFlag,
9,
]);
connector.handleFrameForTest([respCodeAutoAddConfig, autoAddChatFlag]);
expect(connector.autoAddMaxHops, 9);
},
);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.