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
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…
Reference in new issue