mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-11 19:17:04 +10:00
feat: Add community management features with QR code scanning
- Implement Community model for managing community data, including secret handling and PSK derivation. - Create CommunityQrScannerScreen for scanning and joining communities via QR codes. - Develop CommunityStore for persisting community data using SharedPreferences. - Introduce QrCodeDisplay widget for displaying QR codes with customizable options. - Add QrScannerWidget for reusable QR code scanning functionality with validation and controls.
This commit is contained in:
@@ -73,6 +73,35 @@ class Channel {
|
||||
return Uint8List.fromList(hash.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community public channel using HMAC-SHA256.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:__public__")[:16]
|
||||
///
|
||||
/// This creates a channel that is "public" only to members who have
|
||||
/// the community secret. Outsiders see only opaque IDs.
|
||||
static Uint8List deriveCommunityPublicPsk(Uint8List secret) {
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:__public__'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community hashtag channel using HMAC-SHA256.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:" + normalized_name)[:16]
|
||||
///
|
||||
/// Community hashtag channels are deterministic for all members
|
||||
/// (same name => same id) but impossible to enumerate/guess without K.
|
||||
static Uint8List deriveCommunityHashtagPsk(Uint8List secret, String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:$normalized'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Normalize a hashtag name for consistent community PSK derivation.
|
||||
/// Strips leading #, converts to lowercase, trims whitespace.
|
||||
static String _normalizeCommunityHashtag(String hashtag) {
|
||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
static String formatPskHex(Uint8List psk) {
|
||||
return _bytesToHex(psk);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
/// Represents a community with a shared secret for deriving channel PSKs.
|
||||
///
|
||||
/// A Community is a namespace with a shared secret K (32 random bytes),
|
||||
/// distributed via QR code. Members can create Community Public Channels
|
||||
/// and Community Hashtag Channels that are opaque to outsiders.
|
||||
class Community {
|
||||
/// Unique identifier for local storage
|
||||
final String id;
|
||||
|
||||
/// Display name for the community
|
||||
final String name;
|
||||
|
||||
/// The 32-byte shared secret (K)
|
||||
final Uint8List secret;
|
||||
|
||||
/// Timestamp when the community was created/joined
|
||||
final DateTime createdAt;
|
||||
|
||||
/// List of hashtag channel names (without #) that have been added
|
||||
final List<String> hashtagChannels;
|
||||
|
||||
Community({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.secret,
|
||||
required this.createdAt,
|
||||
List<String>? hashtagChannels,
|
||||
}) : hashtagChannels = hashtagChannels ?? [];
|
||||
|
||||
/// Generate a new community with a random 32-byte secret
|
||||
factory Community.create({
|
||||
required String id,
|
||||
required String name,
|
||||
}) {
|
||||
final random = Random.secure();
|
||||
final secret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
secret[i] = random.nextInt(256);
|
||||
}
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a community from QR code JSON data
|
||||
factory Community.fromQrData(String id, String qrData) {
|
||||
final json = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') {
|
||||
throw const FormatException('Invalid QR code type');
|
||||
}
|
||||
if (json['v'] != 1) {
|
||||
throw const FormatException('Unsupported QR code version');
|
||||
}
|
||||
|
||||
final name = json['name'] as String;
|
||||
final secretBase64 = json['k'] as String;
|
||||
final secret = base64Url.decode(secretBase64);
|
||||
|
||||
if (secret.length != 32) {
|
||||
throw const FormatException('Invalid secret length');
|
||||
}
|
||||
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: Uint8List.fromList(secret),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a community from storage JSON
|
||||
factory Community.fromJson(Map<String, dynamic> json) {
|
||||
return Community(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
secret: base64Decode(json['secret'] as String),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int),
|
||||
hashtagChannels: (json['hashtag_channels'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON for storage
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'secret': base64Encode(secret),
|
||||
'created_at': createdAt.millisecondsSinceEpoch,
|
||||
'hashtag_channels': hashtagChannels,
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate QR code JSON payload for sharing
|
||||
String toQrJson() {
|
||||
return jsonEncode({
|
||||
'v': 1,
|
||||
'type': 'meshcore_community',
|
||||
'name': name,
|
||||
'k': base64Url.encode(secret),
|
||||
});
|
||||
}
|
||||
|
||||
/// Derive the public Community ID from the secret.
|
||||
/// This is safe to display/log since it's one-way derived.
|
||||
/// CID = SHA256("community:v1" || K)
|
||||
String get communityId {
|
||||
final data = utf8.encode('community:v1') + secret;
|
||||
final hash = crypto.sha256.convert(data).bytes;
|
||||
return _bytesToHex(Uint8List.fromList(hash));
|
||||
}
|
||||
|
||||
/// Short version of community ID for display (first 8 chars)
|
||||
String get shortCommunityId => communityId.substring(0, 8);
|
||||
|
||||
/// Derive PSK for community public channel.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:__public__")[:16]
|
||||
Uint8List deriveCommunityPublicPsk() {
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:__public__'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community hashtag channel.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:" + normalized_name)[:16]
|
||||
Uint8List deriveCommunityHashtagPsk(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:$normalized'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Check if QR data is valid community data
|
||||
static bool isValidQrData(String data) {
|
||||
try {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') return false;
|
||||
if (json['v'] != 1) return false;
|
||||
if (json['name'] == null || (json['name'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['k'] == null) return false;
|
||||
final secret = base64Url.decode(json['k'] as String);
|
||||
return secret.length == 32;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a hashtag name for consistent PSK derivation.
|
||||
/// Strips leading #, converts to lowercase, trims whitespace.
|
||||
static String _normalizeCommunityHashtag(String hashtag) {
|
||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/// Add a hashtag channel to this community's list
|
||||
Community addHashtagChannel(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
if (hashtagChannels.contains(normalized)) {
|
||||
return this;
|
||||
}
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: [...hashtagChannels, normalized],
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a hashtag channel from this community's list
|
||||
Community removeHashtagChannel(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: hashtagChannels.where((h) => h != normalized).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this community with a new secret
|
||||
Community withNewSecret(Uint8List newSecret) {
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: newSecret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: hashtagChannels,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this community with a regenerated random secret
|
||||
Community withRegeneratedSecret() {
|
||||
final random = Random.secure();
|
||||
final newSecret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
newSecret[i] = random.nextInt(256);
|
||||
}
|
||||
return withNewSecret(newSecret);
|
||||
}
|
||||
|
||||
/// Extract secret from QR data (for updating existing community)
|
||||
static Uint8List? extractSecretFromQrData(String qrData) {
|
||||
try {
|
||||
final json = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') return null;
|
||||
if (json['v'] != 1) return null;
|
||||
final secretBase64 = json['k'] as String;
|
||||
final secret = base64Url.decode(secretBase64);
|
||||
if (secret.length != 32) return null;
|
||||
return Uint8List.fromList(secret);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Community &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
Reference in New Issue
Block a user