Add UI for setting repeater identity keys

This adds a simple UI in repeater management settings for remotely
setting a repeater's private key. A key can be pasted from an external
key generator, or a random one can be generated here, optionally with a
specified prefix.

If a prefix is specified, searching is done in 30ms batches in order
to keep the UI responsive and show the progress indicator animation
smoothly. Searches can be interrupted. An estimation of search length is
given as expected number of generated key pairs. (A future improvement
would be to observe keys per second performance and give time estimates
based on that.)

When it generates a new private key on your behalf, it can tell you
what the corresponding public key will be. Unfortunately, if you paste
one in from another source, it can't compute the corresponding public
key. This is because the standard Dart crypto library doesn't provide
that capability, nor does it give access to the elliptic curve functions
it implements so we could do it ourselves. Our only recourse would be to
copy the source for all those things, and I didn't think it was worth
it.

This will not check the validity of a pasted key, but the remote
repeater will. It does limit it to the correct length and hexadecimal
charset.

Prefixes are limited to 4 characters. There's no technical reason for
this, but users are unlikely to want more, and I think it would just be
a bad experience to allow more and spin for a very long time. If someone
really wants a longer one, they can use an external generator like they
do now.

Possible TODO: rewrite background processing using isolates.

Closes #142.
This commit is contained in:
Seth Golub
2026-05-28 10:57:50 -07:00
parent 5670ab0067
commit e1a4900dc0
29 changed files with 1169 additions and 44 deletions
+3 -11
View File
@@ -1,9 +1,9 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:meshcore_open/storage/channel_message_store.dart';
import 'package:meshcore_open/utils/keys.dart';
import 'package:meshcore_open/utils/platform_info.dart';
import 'package:meshcore_open/widgets/app_bar.dart';
import 'package:provider/provider.dart';
@@ -938,11 +938,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
);
return;
}
final random = Random.secure();
final psk = Uint8List(16);
for (int i = 0; i < 16; i++) {
psk[i] = random.nextInt(256);
}
final psk = randomBytes(16);
Navigator.pop(sheetContext);
await connector.setChannel(
nextIndex,
@@ -1571,11 +1567,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
icon: const Icon(Icons.casino),
tooltip: sheetContext.l10n.channels_generateRandomPsk,
onPressed: () {
final random = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = random.nextInt(256);
}
final bytes = randomBytes(16);
pskController.text = Channel.formatPskHex(bytes);
},
),
+169 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:typed_data';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
@@ -11,6 +12,7 @@ import '../services/storage_service.dart';
import '../theme/mesh_theme.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/routing_sheet.dart';
import '../utils/keys.dart';
import '../helpers/snack_bar_builder.dart';
class RepeaterSettingsScreen extends StatefulWidget {
@@ -44,6 +46,7 @@ enum _SettingField {
advertInterval,
floodAdvertInterval,
pathHashMode,
prvKey,
txDelay,
directTxDelay,
intThresh,
@@ -109,6 +112,8 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
bool _refreshingIntThresh = false;
bool _refreshingAgcResetInterval = false;
bool _runningAction = false;
bool _searchingForKeyPair = false;
bool _stopSearchingForKeyPair = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
@@ -154,6 +159,11 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Advanced
int _pathHashMode = 0; // 0-2
final TextEditingController _prvKeyController = TextEditingController();
final TextEditingController _pubKeyController = TextEditingController();
final TextEditingController _prvKeyPrefixController = TextEditingController();
final KeyPairSearcher _keyPairSearcher = KeyPairSearcher();
final int _searchChunkTime = 30; // time in ms to search before yielding
final TextEditingController _txDelayController = TextEditingController();
final TextEditingController _directTxDelayController =
TextEditingController();
@@ -860,6 +870,12 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
command: 'set path.hash.mode $_pathHashMode',
));
}
if (_dirtyFields.contains(_SettingField.prvKey)) {
final v = _prvKeyController.text.trim();
if (v != "") {
pending.add((field: _SettingField.prvKey, command: 'set prv.key $v'));
}
}
if (_dirtyFields.contains(_SettingField.txDelay) &&
_txDelayController.text.isNotEmpty) {
final v = double.tryParse(_txDelayController.text.trim());
@@ -1005,6 +1021,36 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}
}
void _startSearchingForKeyPair() async {
if (_searchingForKeyPair) return;
setState(() {
_searchingForKeyPair = true;
_stopSearchingForKeyPair = false;
});
_searchForKeyPair();
}
void _searchForKeyPair() async {
if (_stopSearchingForKeyPair) {
setState(() => _searchingForKeyPair = false);
return;
}
final String prefix = _prvKeyPrefixController.text.trim();
MCKeyPair? keys = await _keyPairSearcher.findMatchingKeyPair(
prefix,
_searchChunkTime,
);
if (keys == null) {
// Ran out of time; schedule another attempt.
Timer.run(_searchForKeyPair);
} else {
setState(() => _searchingForKeyPair = false);
_prvKeyController.text = pubKeyToHex(keys.private);
_pubKeyController.text = pubKeyToHex(keys.public);
_markChanged(_SettingField.prvKey);
}
}
Widget _buildInlineRefreshButton({
required bool isRefreshing,
required VoidCallback onRefresh,
@@ -1069,6 +1115,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_buildOwnerInfoCard(),
_buildActionsCard(),
_buildAdvancedCard(),
_buildKeysCard(),
const SizedBox(height: 16),
_buildDangerZoneCard(),
],
@@ -1984,6 +2031,127 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
);
}
Widget _buildKeysCard() {
final l10n = context.l10n;
return MeshCard(
child: ExpansionTile(
leading: const Icon(Icons.vpn_key),
title: Text(
l10n.repeater_keySettings,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
subtitle: Text(l10n.repeater_keySettingsSubtitle),
childrenPadding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _prvKeyController,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9a-fA-F]")),
],
decoration: InputDecoration(
labelText: l10n.repeater_prvKey,
helperText: l10n.repeater_prvKeyHelper,
helperMaxLines: 3,
border: const OutlineInputBorder(),
),
onChanged: (_) {
_pubKeyController.text = "";
if (_prvKeyController.text.isNotEmpty) {
_markChanged(_SettingField.prvKey);
}
},
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 8),
child: _searchingForKeyPair
? IconButton(
icon: const Icon(Icons.cancel, size: 24),
onPressed: (() => _stopSearchingForKeyPair = true),
tooltip: l10n.repeater_stopGeneratingPrvKey,
visualDensity: VisualDensity.compact,
)
: IconButton(
icon: const Icon(Icons.casino_rounded, size: 24),
onPressed: () {
_startSearchingForKeyPair();
},
tooltip: l10n.repeater_generatePrvKey,
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
readOnly: true,
controller: _pubKeyController,
style: TextStyle(
color: Theme.of(
context,
).textTheme.headlineSmall?.color?.withValues(alpha: 0.6),
),
decoration: InputDecoration(
labelText: l10n.repeater_pubKey,
helperText: l10n.repeater_pubKeyHelper,
helperMaxLines: 3,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 16),
Padding(
padding: const EdgeInsets.only(top: 12),
child: SizedBox(
width: 24,
height: 24,
child: _searchingForKeyPair
? CircularProgressIndicator(strokeWidth: 2)
: null,
),
),
const SizedBox(width: 8),
],
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _prvKeyPrefixController,
maxLength: 4,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp("[0-9a-fA-F]")),
],
onChanged: (_) => setState(() => {}), // updates helper text
decoration: InputDecoration(
labelText: l10n.repeater_pubKeyPrefix,
helperText: l10n.repeater_pubKeyPrefixHelper(
pow(16, _prvKeyPrefixController.text.length).toInt(),
),
helperMaxLines: 3,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 48),
],
),
],
),
);
}
Widget _buildDangerZoneCard() {
final l10n = context.l10n;
return MeshCard(