fix: refuse an ambiguous nickname instead of picking the first peer (#1689)

getPeerID(for:) and geohash geoNicknames.first(where:) returned the
first match, so /msg and /block could hit the wrong person when two
peers share a name. Accept an unsuffixed nick only when it is unique;
otherwise require the people-list #xxxx suffix.
This commit is contained in:
Taksh Kothari 2026-09-24 13:04:04 +05:30 committed by GitHub
parent 774c88ed97
commit 25a3219a53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 108 additions and 15 deletions

View File

@ -251,17 +251,21 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return peerIndex[peerID]
}
/// Get peer ID for nickname
/// Get peer ID for nickname.
///
/// An unsuffixed name is accepted only when it identifies exactly one peer.
/// Colliding nicknames must be disambiguated with the people-list `#xxxx`
/// suffix (first four hex characters of the peer ID).
func getPeerID(for nickname: String) -> PeerID? {
// Normalize both sides: the query may come from typed content and
// stored names may predate NFC-at-ingest (e.g. persisted favorites).
let target = nickname.normalizedNickname
for peer in peers {
if peer.displayName.normalizedNickname == target || peer.nickname.normalizedNickname == target {
return peer.peerID
guard let id = NicknameLookup.uniquePeerIDString(
for: nickname,
peers: peers.map { peer in
(id: peer.peerID.id, nickname: peer.nickname, displayName: peer.displayName)
}
) else {
return nil
}
return nil
return peers.first(where: { $0.peerID.id == id })?.peerID
}
/// Check if peer is blocked

View File

@ -0,0 +1,29 @@
import Foundation
/// Resolve a typed nickname to a peer ID string.
///
/// An unsuffixed name is accepted only when it identifies exactly one peer.
/// Colliding nicknames must be disambiguated with the people-list `#xxxx`
/// suffix (first four hex characters of the peer ID).
enum NicknameLookup {
static func uniquePeerIDString(
for query: String,
peers: [(id: String, nickname: String, displayName: String)]
) -> String? {
let target = query.normalizedNickname
var matches: [String] = []
for peer in peers {
let nick = peer.nickname.normalizedNickname
let display = peer.displayName.normalizedNickname
let prefix = String(peer.id.prefix(4))
let suffixedNick = (nick + "#" + prefix).normalizedNickname
let suffixedDisplay = (display + "#" + prefix).normalizedNickname
if display == target || nick == target || suffixedNick == target || suffixedDisplay == target {
if !matches.contains(peer.id) {
matches.append(peer.id)
}
}
}
return matches.count == 1 ? matches.first : nil
}
}

View File

@ -518,12 +518,15 @@ final class ChatPeerIdentityCoordinator {
let nickname = nickname.normalizedNickname
switch context.activeChannel {
case .location:
if nickname.contains("#"),
let person = context.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
if nickname.contains("#") {
let people = context.visibleGeohashPeople().filter {
$0.displayName.normalizedNickname == nickname
}
if people.count == 1, let person = people.first {
let conversationKey = PeerID(nostr_: person.id)
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
}
let base = nickname
@ -531,11 +534,15 @@ final class ChatPeerIdentityCoordinator {
.first
.map(String.init)?
.lowercased() ?? nickname.lowercased()
if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let geoMatches = context.geoNicknames.filter { $0.value.lowercased() == base }
if geoMatches.count == 1, let pubkey = geoMatches.keys.first {
let conversationKey = PeerID(nostr_: pubkey)
context.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
if geoMatches.count > 1 {
return nil
}
case .mesh:
break

View File

@ -446,6 +446,27 @@ struct ChatPeerIdentityCoordinatorContextTests {
context.peerIDsByNickname["carol"] = meshPeer
#expect(coordinator.getPeerIDForNickname("carol") == meshPeer)
}
@Test @MainActor
func getPeerIDForNickname_inGeohashChannel_refusesAmbiguousBaseName() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let aliceA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
let aliceB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.geohashPeople = [
GeoPerson(id: aliceA, displayName: "alice#aaaa", lastSeen: Date()),
GeoPerson(id: aliceB, displayName: "alice#bbbb", lastSeen: Date())
]
context.geoNicknames[aliceA] = "alice"
context.geoNicknames[aliceB] = "alice"
context.peerIDsByNickname["alice"] = PeerID(str: "1122334455667788")
#expect(coordinator.getPeerIDForNickname("alice") == nil)
#expect(coordinator.getPeerIDForNickname("alice#aaaa") == PeerID(nostr_: aliceA))
#expect(coordinator.getPeerIDForNickname("alice#bbbb") == PeerID(nostr_: aliceB))
}
@Test @MainActor
func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async {
let context = MockChatPeerIdentityContext()

View File

@ -27,6 +27,38 @@ struct UnifiedPeerServiceTests {
#expect(fingerprint == "fp-1")
}
@Test @MainActor
func getPeerID_acceptsAUniqueNickname() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let alice = PeerID(str: "1111111111111111")
transport.simulateConnect(alice, nickname: "alice")
service.didUpdatePeerSnapshots(transport.currentPeerSnapshots())
#expect(service.getPeerID(for: "alice") == alice)
}
@Test @MainActor
func getPeerID_refusesAmbiguousNicknameUnlessSuffixed() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let aliceA = PeerID(str: "1111111111111111")
let aliceB = PeerID(str: "2222222222222222")
transport.simulateConnect(aliceA, nickname: "alice")
transport.simulateConnect(aliceB, nickname: "alice")
service.didUpdatePeerSnapshots(transport.currentPeerSnapshots())
#expect(service.getPeerID(for: "alice") == nil)
#expect(service.getPeerID(for: "alice#1111") == aliceA)
#expect(service.getPeerID(for: "alice#2222") == aliceB)
}
@Test @MainActor
func isBlocked_usesSocialIdentity() async {
let transport = MockTransport()