feat(#625): parse the stock contact share URI

Adds Contact.fromShareUri / isValidShareUri for the reference-app format
documented in firmware docs/qr_codes.md:

  meshcore://contact/add?name=<url-encoded>&public_key=<64 hex>&type=<1-4>

The payload is a bare public key, not a signed advert, so the parsed
Contact is an identity stub: no path, no position, no rawPacket. A QR is
this same URI rendered visually, so scanning will share this parser.

lastSeen is deliberately the epoch rather than DateTime.now(). It maps to
the firmware's last_advert_timestamp, which the advert handler compares
with 'timestamp <= last_advert_timestamp' and treats as a replay attack
(BaseChatMesh.cpp:142-145). Stamping now would leave the contact
permanently deaf to its own adverts, since advert timestamps come from
the sender's clock and two nodes on this mesh currently advertise with
2024 clocks. A regression test pins this.

The fork's older meshcore://<raw advert hex> form returns null here, so
callers keep routing it to the existing advert import path.

Epic #619. Not yet wired to the UI: the import path needs the send half
(#627) before there is anything to add.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/619-contact-identity-uri
Strycher 3 weeks ago
parent 0311121bf0
commit a9359d2beb

@ -260,6 +260,83 @@ class Contact {
} }
} }
/// Parses a contact from the reference-app share URI, or null if malformed.
///
/// `meshcore://contact/add?name=<url-encoded>&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://<raw advert hex>` 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 @override
bool operator ==(Object other) => bool operator ==(Object other) =>
other is Contact && publicKeyHex == other.publicKeyHex; other is Contact && publicKeyHex == other.publicKeyHex;

@ -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 = <String>[
'', // 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 = <String>[
'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://<raw advert hex>`, 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);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.