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 <noreply@anthropic.com>
epic/816-integration
diffwireauto 1 month ago committed by Strycher
parent e49c0f1998
commit beb6d6434e

@ -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(<int>[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;
}

@ -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)';
}

@ -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);
});
});
}

@ -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);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.