diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 972c04f..09981a1 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -260,6 +260,83 @@ class Contact { } } + /// Parses a contact from the reference-app share URI, or null if malformed. + /// + /// `meshcore://contact/add?name=&public_key=<64 hex>&type=<1-4>` + /// + /// Spec: MeshCore firmware `docs/qr_codes.md`, the format the stock mobile + /// app emits for both its contact QR and its share link. The payload is a + /// BARE public key, not a signed advert, so the result is an identity stub: + /// no path, no position, nothing the mesh has confirmed. A QR is this same + /// URI rendered visually, so scanning shares this parser. (#625) + /// + /// Returns null for the fork's older `meshcore://` form, + /// whose host is the leading hex rather than `contact`. Callers keep handling + /// that separately: it carries a full signed advert and is strictly richer. + static Contact? fromShareUri(String uri) { + final parsed = Uri.tryParse(uri.trim()); + if (parsed == null || parsed.scheme != 'meshcore') return null; + if (parsed.host != 'contact') return null; + if (parsed.path.replaceAll('/', '') != 'add') return null; + + final keyHex = parsed.queryParameters['public_key']; + if (keyHex == null || keyHex.length != pubKeySize * 2) return null; + + final Uint8List publicKey; + try { + publicKey = hex2Uint8List(keyHex); + } on FormatException { + return null; + } + + // `type` is optional; stock always emits it, but a key alone is still a + // usable identity. Out-of-range values are rejected rather than clamped: + // in a three-parameter URI an impossible type signals corruption, and + // silently mis-typing a contact is a user-visible defect. Widen this + // deliberately if the spec ever adds a type. + final typeRaw = parsed.queryParameters['type']; + int type = advTypeChat; + if (typeRaw != null && typeRaw.isNotEmpty) { + final parsedType = int.tryParse(typeRaw); + if (parsedType == null || + parsedType < advTypeChat || + parsedType > advTypeSensor) { + return null; + } + type = parsedType; + } + + final name = parsed.queryParameters['name']; + + return Contact( + publicKey: publicKey, + // Matches fromFrame's convention for a nameless contact. + name: (name == null || name.isEmpty) ? 'Unknown' : name, + type: type, + flags: 0, + // Flood until the mesh teaches us a path. Mirrors the firmware's + // OUT_PATH_UNKNOWN for a contact that has never been routed to. + pathLength: -1, + path: Uint8List(0), + // Deliberately the epoch, NOT DateTime.now(). + // + // This maps to the firmware's `last_advert_timestamp`, which the advert + // handler compares with `timestamp <= last_advert_timestamp` and treats + // a non-greater value as a replay attack (`BaseChatMesh.cpp:142-145`). + // Stamping "now" would leave this contact permanently deaf to its own + // adverts, because advert timestamps come from the SENDER's clock and + // clocks in the field run years behind. Zero lets any genuine advert win + // and upgrade the stub in place. (#620) + lastSeen: DateTime.fromMillisecondsSinceEpoch(0), + // No advert packet, so this contact cannot be re-shared until one + // arrives. The share path already gates on rawPacket. + rawPacket: null, + ); + } + + /// True if [uri] is a valid reference-app contact share URI. (#625) + static bool isValidShareUri(String uri) => fromShareUri(uri) != null; + @override bool operator ==(Object other) => other is Contact && publicKeyHex == other.publicKeyHex; diff --git a/test/models/contact_share_uri_test.dart b/test/models/contact_share_uri_test.dart new file mode 100644 index 0000000..05ca25c --- /dev/null +++ b/test/models/contact_share_uri_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/models/contact.dart'; + +/// A synthetic 64-hex key. Not a real node. +const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'; + +void main() { + group('Contact share URI (#625)', () { + test('parses the documented spec example verbatim', () { + // Straight from the firmware docs/qr_codes.md "Add Contact" example. + final c = Contact.fromShareUri( + 'meshcore://contact/add' + '?name=Example+Contact' + '&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1' + '&type=1', + ); + expect(c, isNotNull); + // `+` must decode to a space, or stock-emitted names arrive mangled. + expect(c!.name, 'Example Contact'); + expect( + c.publicKeyHex, + '9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1', + ); + expect(c.type, advTypeChat); + }); + + test('percent-encoded names round-trip, including emoji', () { + final c = Contact.fromShareUri( + 'meshcore://contact/add?name=${Uri.encodeComponent('DIRT WIZARD 🧙')}' + '&public_key=$_key&type=1', + ); + expect(c, isNotNull); + expect(c!.name, 'DIRT WIZARD 🧙'); + }); + + test('a parsed contact is an unverified stub, not a routed contact', () { + final c = Contact.fromShareUri( + 'meshcore://contact/add?name=Bob&public_key=$_key&type=1', + )!; + + // The load-bearing assertion for #620: lastSeen maps to the firmware's + // last_advert_timestamp. Anything other than the epoch would trip the + // advert replay guard and leave this contact permanently deaf to its + // own adverts. + expect(c.lastSeen, DateTime.fromMillisecondsSinceEpoch(0)); + + // Flood until a path is learned. + expect(c.pathLength, -1); + expect(c.path, isEmpty); + + // No signed advert, so it cannot be re-shared yet. + expect(c.rawPacket, isNull); + expect(c.flags, 0); + }); + + test('type is optional and defaults to companion', () { + final c = Contact.fromShareUri( + 'meshcore://contact/add?name=Bob&public_key=$_key', + ); + expect(c, isNotNull); + expect(c!.type, advTypeChat); + }); + + test('accepts every documented contact type', () { + for (final t in [ + advTypeChat, + advTypeRepeater, + advTypeRoom, + advTypeSensor, + ]) { + final c = Contact.fromShareUri( + 'meshcore://contact/add?name=N&public_key=$_key&type=$t', + ); + expect(c, isNotNull, reason: 'type $t should parse'); + expect(c!.type, t); + } + }); + + test('rejects an out-of-range or non-numeric type', () { + for (final t in ['0', '5', '255', '-1', 'chat', '1.5']) { + expect( + Contact.fromShareUri( + 'meshcore://contact/add?name=N&public_key=$_key&type=$t', + ), + isNull, + reason: 'type "$t" should be rejected', + ); + } + }); + + test('a missing name falls back rather than failing the add', () { + final c = Contact.fromShareUri( + 'meshcore://contact/add?public_key=$_key&type=1', + ); + expect(c, isNotNull); + expect(c!.name, 'Unknown'); + }); + + test('rejects a malformed or wrong-length public key', () { + final bad = [ + '', // absent value + '00112233', // too short + '${_key}ff', // too long + _key.replaceRange(0, 2, 'zz'), // right length, not hex + ]; + for (final k in bad) { + expect( + Contact.fromShareUri( + 'meshcore://contact/add?name=N&public_key=$k&type=1', + ), + isNull, + reason: 'key "$k" should be rejected', + ); + } + // Entirely absent parameter. + expect( + Contact.fromShareUri('meshcore://contact/add?name=N&type=1'), + isNull, + ); + }); + + test('rejects the wrong scheme, host or path', () { + final bad = [ + 'https://contact/add?public_key=$_key', + 'meshcore://channel/add?public_key=$_key', + 'meshcore://contact/remove?public_key=$_key', + 'meshcore://contact?public_key=$_key', + 'not a uri at all', + '', + ]; + for (final u in bad) { + expect(Contact.fromShareUri(u), isNull, reason: '"$u" should reject'); + } + }); + + test('leading and trailing whitespace is tolerated', () { + expect( + Contact.fromShareUri( + ' meshcore://contact/add?name=N&public_key=$_key&type=1\n', + ), + isNotNull, + ); + }); + + test( + 'returns null for the legacy advert-hex form so callers fall back', + () { + // The fork's older share format is `meshcore://`, whose + // host is the hex itself. It carries a full signed advert and must keep + // going down its own import path, not this one. + expect(Contact.fromShareUri('meshcore://${_key}aabbcc'), isNull); + }, + ); + + test('isValidShareUri agrees with fromShareUri', () { + const good = 'meshcore://contact/add?name=N&public_key=$_key&type=2'; + expect(Contact.isValidShareUri(good), isTrue); + expect(Contact.isValidShareUri('meshcore://channel/add?name=x'), isFalse); + }); + }); +}