From beb6d6434e485eb34bb909886e5fdc23a9019edb Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:07:54 -0400 Subject: [PATCH] feat(#820): region model + name-derived transport key (Epic #813 T1) Client-side region transport-key derivation matching firmware RegionMap/TransportKeyStore: key = first 16 bytes of SHA256("#"+name), a Region value model, and the HMAC on-air code. Known-answer tests use independent vectors. No firmware change. Co-Authored-By: Claude Opus 4.8 --- lib/helpers/region_key.dart | 49 ++++++++++++++++++++++ lib/models/region.dart | 32 ++++++++++++++ test/helpers/region_key_test.dart | 69 +++++++++++++++++++++++++++++++ test/models/region_test.dart | 26 ++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 lib/helpers/region_key.dart create mode 100644 lib/models/region.dart create mode 100644 test/helpers/region_key_test.dart create mode 100644 test/models/region_test.dart diff --git a/lib/helpers/region_key.dart b/lib/helpers/region_key.dart new file mode 100644 index 00000000..e3e0e1f8 --- /dev/null +++ b/lib/helpers/region_key.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// Region transport-key derivation, matching the MeshCore firmware +/// (`RegionMap.getTransportKeysFor` -> `TransportKeyStore.getAutoKeyFor`). +/// +/// A public "hashtag" region's transport key is the first 16 bytes of +/// `SHA256` of the region name carrying exactly one leading `#`: auto-hashtag +/// names are hashed as stored, bare names get a `#` prepended. `$`-prefixed +/// regions are private, their keys live in a hardware keystore and are not +/// derivable from the name. Names discovered over the air arrive `#`-stripped, +/// so the client re-adds the `#` here. +const int _keyLength = 16; + +/// True for a private (`$`-prefixed) region, whose key is not name-derivable. +bool isPrivateRegionName(String name) => name.trim().startsWith(r'$'); + +/// The region name as the firmware hashes it: exactly one leading `#`. +String normalizeHashtag(String name) { + final stripped = name.trim().replaceFirst(RegExp(r'^#+'), ''); + return '#$stripped'; +} + +/// First 16 bytes of `SHA256("#"+name)`. +/// +/// Returns null when [name] is blank or a private (`$`) region. +Uint8List? transportKeyForName(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty || isPrivateRegionName(trimmed)) return null; + final digest = sha256.convert(utf8.encode(normalizeHashtag(trimmed))).bytes; + return Uint8List.fromList(digest.sublist(0, _keyLength)); +} + +/// The 2-byte on-air transport code the firmware stamps on a scoped packet: +/// `HMAC-SHA256(key, payloadType || payload)`, first two bytes read as a +/// little-endian `uint16` (firmware `TransportKey::calcTransportCode`). +int onAirCode(Uint8List key, int payloadType, Uint8List payload) { + final mac = Hmac(sha256, key).convert([payloadType, ...payload]).bytes; + return reserveTransportCode(mac[0] | (mac[1] << 8)); +} + +/// Firmware reserves `0x0000` and `0xFFFF`, bumping each by one. +int reserveTransportCode(int code) { + if (code == 0x0000) return 0x0001; + if (code == 0xFFFF) return 0xFFFE; + return code; +} diff --git a/lib/models/region.dart b/lib/models/region.dart new file mode 100644 index 00000000..f2d65bac --- /dev/null +++ b/lib/models/region.dart @@ -0,0 +1,32 @@ +import 'dart:typed_data'; + +import 'package:meshcore_open/helpers/region_key.dart'; + +/// A flood region as discovered from a repeater. +/// +/// [name] is the `#`-stripped region name the firmware reports over the air. +/// Value equality is by name so a discovery service can dedupe replies. +class Region { + final String name; + + const Region(this.name); + + /// A wildcard region (matches a family of names) rather than a single one. + bool get isWildcard => name.contains('*'); + + /// A private region whose key is not derivable from its name. + bool get isPrivate => isPrivateRegionName(name); + + /// The 16-byte transport key for this region, or null when it is private + /// or blank. + Uint8List? get transportKey => transportKeyForName(name); + + @override + bool operator ==(Object other) => other is Region && other.name == name; + + @override + int get hashCode => name.hashCode; + + @override + String toString() => 'Region($name)'; +} diff --git a/test/helpers/region_key_test.dart b/test/helpers/region_key_test.dart new file mode 100644 index 00000000..34d5f4ef --- /dev/null +++ b/test/helpers/region_key_test.dart @@ -0,0 +1,69 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/region_key.dart'; + +// Known-answer vectors computed with an independent implementation +// (Python hashlib). The firmware derives a region's transport key as the +// first 16 bytes of SHA256 of the hashtag name WITH exactly one leading '#' +// (firmware: RegionMap.getTransportKeysFor -> TransportKeyStore.getAutoKeyFor, +// which hashes the stored name; bare names get '#' prepended). Discovered +// region names arrive '#'-stripped, so the client re-adds the '#'. +String _hex(Uint8List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + +void main() { + group('transportKeyForName', () { + test('first 16 bytes of SHA256("#"+name) for a bare name', () { + expect( + _hex(transportKeyForName('oki')!), + '599fe2546002709fa493b15de58bdd0d', + ); + expect( + _hex(transportKeyForName('test')!), + '9cd8fcf22a47333b591d96a2b848b73f', + ); + }); + + test('idempotent on a leading # (auto-hashtag names hash as-is)', () { + expect( + _hex(transportKeyForName('#oki')!), + _hex(transportKeyForName('oki')!), + ); + }); + + test('key is 16 bytes', () { + expect(transportKeyForName('oki')!.length, 16); + }); + + test('private (\$-prefixed) regions are not name-derivable', () { + expect(isPrivateRegionName(r'$secret'), isTrue); + expect(transportKeyForName(r'$secret'), isNull); + }); + + test('empty or blank name yields no key', () { + expect(transportKeyForName(''), isNull); + expect(transportKeyForName(' '), isNull); + }); + }); + + group('onAirCode', () { + test('matches the firmware HMAC transport code (little-endian uint16)', () { + final key = transportKeyForName('oki')!; + final code = onAirCode( + key, + 0x00, + Uint8List.fromList([0xde, 0xad, 0xbe, 0xef]), + ); + expect(code, 0x959d); + }); + + test('reserves codes 0x0000 and 0xFFFF', () { + // Both reserved values are bumped by one, matching + // TransportKey::calcTransportCode in firmware. + expect(reserveTransportCode(0x0000), 0x0001); + expect(reserveTransportCode(0xFFFF), 0xFFFE); + expect(reserveTransportCode(0x1234), 0x1234); + }); + }); +} diff --git a/test/models/region_test.dart b/test/models/region_test.dart new file mode 100644 index 00000000..54614b87 --- /dev/null +++ b/test/models/region_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/models/region.dart'; + +void main() { + test('value equality by name (lets discovery dedupe replies)', () { + expect(const Region('oki'), const Region('oki')); + expect(const Region('oki').hashCode, const Region('oki').hashCode); + expect(const Region('oki') == const Region('ked'), isFalse); + final duplicates = [const Region('oki'), const Region('oki')]; + expect(duplicates.toSet().length, 1); + }); + + test('derives its 16-byte transport key', () { + final key = const Region('oki').transportKey; + expect(key, isNotNull); + expect(key!.length, 16); + }); + + test('flags wildcard and private regions', () { + expect(const Region('*').isWildcard, isTrue); + expect(const Region(r'$secret').isPrivate, isTrue); + expect(const Region('oki').isWildcard, isFalse); + expect(const Region('oki').isPrivate, isFalse); + expect(const Region(r'$secret').transportKey, isNull); + }); +}