feat(#630): show how far a contact identity is confirmed
Adds a calm three-state indicator beside each contact name, per the owner steer: a green check for advert-verified, a neutral outline check for key-confirmed, a muted key for key-only. No amber and no hazard glyph, because nothing is wrong with a key-added contact. The scale reads as how much we know, never as how risky. The states are not cosmetic: - advertVerified: a signed advert arrived, so the node itself asserted its name, type and position, and the raw packet is stored, which is also what makes the contact re-shareable. - keyConfirmed: a message with this contact went through. Direct messages are encrypted with an ECDH secret derived from the contact key, and the ACK is computed over the decrypted plaintext (BaseChatMesh.cpp:442,451), so this is proof the holder of the matching private key is live. It does NOT prove the person is who the name claims. - keyOnly: someone supplied a key and nothing has confirmed it on air. Costs almost nothing to compute. Contact.isAdvertVerified falls out of the epoch last_advert_timestamp that #627 already writes, and it clears itself when a real advert rewrites the field. The advert check runs first so only an unconfirmed contact pays for a message scan, which keeps a long contact list cheap. Also fixes a defect the epoch sentinel introduced: _formatLastSeen ran the epoch through the relative formatter and claimed a key-added contact was last seen tens of thousands of days ago. It now reads Not heard yet. Epic #619. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/619-contact-identity-uri
parent
3fa5b4ee2f
commit
2643608e2d
@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
|
||||
/// How far a contact identity has actually been confirmed. (#630)
|
||||
enum ContactVerification {
|
||||
/// Added from a bare key. Nothing has confirmed it on air.
|
||||
keyOnly,
|
||||
|
||||
/// A message with this contact went through. That is cryptographic proof
|
||||
/// the holder of the matching private key is live and reachable, because
|
||||
/// direct messages are encrypted with an ECDH secret derived from this
|
||||
/// contact key, and the ACK is computed over the decrypted plaintext
|
||||
/// (firmware `BaseChatMesh.cpp:442,451`). It does NOT prove the person is
|
||||
/// who the name claims: names are display, keys are identity.
|
||||
keyConfirmed,
|
||||
|
||||
/// A signed advert has been received. Strongest state: the node itself
|
||||
/// asserted its name, type and position, and the raw advert is stored, so
|
||||
/// the contact can also be re-shared.
|
||||
advertVerified,
|
||||
}
|
||||
|
||||
/// Resolves the verification state for [contact].
|
||||
///
|
||||
/// The advert check is first because it is free and covers most contacts; only
|
||||
/// an unverified contact pays for a message scan, which keeps this cheap on a
|
||||
/// long contact list.
|
||||
ContactVerification resolveContactVerification(
|
||||
Contact contact,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
if (contact.isAdvertVerified) return ContactVerification.advertVerified;
|
||||
final delivered = connector
|
||||
.getMessages(contact)
|
||||
.any((m) => m.status == MessageStatus.delivered);
|
||||
return delivered
|
||||
? ContactVerification.keyConfirmed
|
||||
: ContactVerification.keyOnly;
|
||||
}
|
||||
|
||||
/// A small, deliberately calm indicator of how far a contact is confirmed.
|
||||
///
|
||||
/// Owner steer (#630): a green check for fully verified and a different icon
|
||||
/// otherwise. Explicitly NOT amber and NOT a hazard glyph, because nothing is
|
||||
/// wrong with a key-added contact. The scale reads as "how much we know",
|
||||
/// never as "how risky".
|
||||
class ContactVerificationBadge extends StatelessWidget {
|
||||
const ContactVerificationBadge({
|
||||
super.key,
|
||||
required this.contact,
|
||||
this.size = 15,
|
||||
});
|
||||
|
||||
final Contact contact;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = resolveContactVerification(
|
||||
contact,
|
||||
context.read<MeshCoreConnector>(),
|
||||
);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
|
||||
final (IconData icon, Color color, String tooltip) = switch (state) {
|
||||
ContactVerification.advertVerified => (
|
||||
Icons.verified,
|
||||
// The one deliberately positive colour in the set.
|
||||
Colors.green,
|
||||
l10n.contacts_verifiedByAdvert,
|
||||
),
|
||||
ContactVerification.keyConfirmed => (
|
||||
Icons.check_circle_outline,
|
||||
scheme.onSurfaceVariant,
|
||||
l10n.contacts_verifiedByMessage,
|
||||
),
|
||||
ContactVerification.keyOnly => (
|
||||
Icons.key_outlined,
|
||||
scheme.onSurfaceVariant,
|
||||
l10n.contacts_verifiedKeyOnly,
|
||||
),
|
||||
};
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Icon(icon, size: size, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,171 @@
|
||||
// Verification-state tests (#630).
|
||||
//
|
||||
// The three states are not cosmetic. advertVerified means the node itself
|
||||
// asserted its identity in a signed advert. keyConfirmed means a message went
|
||||
// through, which only works with the matching private key. keyOnly means
|
||||
// someone typed a key and nothing has confirmed it on air.
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/models/message.dart';
|
||||
import 'package:meshcore_open/widgets/contact_verification_badge.dart';
|
||||
|
||||
const _key = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff';
|
||||
|
||||
Contact _contact({required DateTime lastSeen}) => Contact(
|
||||
publicKey: hex2Uint8List(_key),
|
||||
name: 'Bob',
|
||||
type: advTypeChat,
|
||||
pathLength: -1,
|
||||
path: Uint8List(0),
|
||||
lastSeen: lastSeen,
|
||||
);
|
||||
|
||||
Contact _keyOnly() =>
|
||||
_contact(lastSeen: DateTime.fromMillisecondsSinceEpoch(0));
|
||||
Contact _adverted() => _contact(lastSeen: DateTime(2026, 9, 6, 12));
|
||||
|
||||
Message _msg(MessageStatus status) => Message(
|
||||
senderKey: hex2Uint8List(_key),
|
||||
text: 'hi',
|
||||
isOutgoing: true,
|
||||
timestamp: DateTime(2026, 9, 6),
|
||||
status: status,
|
||||
);
|
||||
|
||||
class _Conn extends MeshCoreConnector {
|
||||
_Conn(this.messages);
|
||||
final List<Message> messages;
|
||||
|
||||
@override
|
||||
List<Message> getMessages(Contact contact) => messages;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('Contact.isAdvertVerified (#630)', () {
|
||||
test('a key-only contact carries the epoch and is not advert-verified', () {
|
||||
expect(_keyOnly().isAdvertVerified, isFalse);
|
||||
});
|
||||
|
||||
test('any real advert timestamp counts as verified', () {
|
||||
expect(_adverted().isAdvertVerified, isTrue);
|
||||
});
|
||||
|
||||
test('the parser output is unverified by construction', () {
|
||||
// Ties the model getter to what fromShareUri actually produces, so the
|
||||
// two cannot drift.
|
||||
final parsed = Contact.fromShareUri(
|
||||
'meshcore://contact/add?name=Bob&public_key=$_key&type=1',
|
||||
)!;
|
||||
expect(parsed.isAdvertVerified, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveContactVerification (#630)', () {
|
||||
test('an adverted contact is advertVerified even with no messages', () {
|
||||
expect(
|
||||
resolveContactVerification(_adverted(), _Conn(const [])),
|
||||
ContactVerification.advertVerified,
|
||||
);
|
||||
});
|
||||
|
||||
test('an advert wins over message history', () {
|
||||
// The advert is strictly stronger: it is signed and it stores the raw
|
||||
// packet that makes the contact re-shareable.
|
||||
expect(
|
||||
resolveContactVerification(
|
||||
_adverted(),
|
||||
_Conn([_msg(MessageStatus.delivered)]),
|
||||
),
|
||||
ContactVerification.advertVerified,
|
||||
);
|
||||
});
|
||||
|
||||
test('a key-only contact with no traffic is keyOnly', () {
|
||||
expect(
|
||||
resolveContactVerification(_keyOnly(), _Conn(const [])),
|
||||
ContactVerification.keyOnly,
|
||||
);
|
||||
});
|
||||
|
||||
test('a delivered message upgrades a key-only contact to keyConfirmed', () {
|
||||
expect(
|
||||
resolveContactVerification(
|
||||
_keyOnly(),
|
||||
_Conn([_msg(MessageStatus.sent), _msg(MessageStatus.delivered)]),
|
||||
),
|
||||
ContactVerification.keyConfirmed,
|
||||
);
|
||||
});
|
||||
|
||||
test('unacked traffic does NOT confirm the key', () {
|
||||
// sent means it left the radio. failed and pending prove nothing at all.
|
||||
// Only delivered means the far end produced the right ACK, which needs
|
||||
// the plaintext, which needs the matching private key.
|
||||
for (final s in [
|
||||
MessageStatus.pending,
|
||||
MessageStatus.sent,
|
||||
MessageStatus.failed,
|
||||
]) {
|
||||
expect(
|
||||
resolveContactVerification(_keyOnly(), _Conn([_msg(s)])),
|
||||
ContactVerification.keyOnly,
|
||||
reason: '$s must not count as confirmation',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('ContactVerificationBadge rendering (#630)', () {
|
||||
Future<void> pump(WidgetTester tester, Contact c, _Conn conn) =>
|
||||
tester.pumpWidget(
|
||||
ChangeNotifierProvider<MeshCoreConnector>.value(
|
||||
value: conn,
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(body: ContactVerificationBadge(contact: c)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('advert-verified shows the green check', (tester) async {
|
||||
await pump(tester, _adverted(), _Conn(const []));
|
||||
final icon = tester.widget<Icon>(find.byType(Icon));
|
||||
expect(icon.icon, Icons.verified);
|
||||
expect(icon.color, Colors.green);
|
||||
});
|
||||
|
||||
testWidgets('key-confirmed shows a neutral check, not green', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, _keyOnly(), _Conn([_msg(MessageStatus.delivered)]));
|
||||
final icon = tester.widget<Icon>(find.byType(Icon));
|
||||
expect(icon.icon, Icons.check_circle_outline);
|
||||
expect(icon.color, isNot(Colors.green));
|
||||
});
|
||||
|
||||
testWidgets('key-only shows a muted key and nothing alarming', (
|
||||
tester,
|
||||
) async {
|
||||
await pump(tester, _keyOnly(), _Conn(const []));
|
||||
final icon = tester.widget<Icon>(find.byType(Icon));
|
||||
expect(icon.icon, Icons.key_outlined);
|
||||
// Owner steer: no amber, no hazard glyph. Nothing is wrong with this
|
||||
// contact, it is just less confirmed.
|
||||
expect(icon.icon, isNot(Icons.warning));
|
||||
expect(icon.icon, isNot(Icons.warning_amber));
|
||||
expect(icon.icon, isNot(Icons.error_outline));
|
||||
expect(icon.color, isNot(Colors.amber));
|
||||
expect(icon.color, isNot(Colors.red));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue