Cover the connected peer, and stop comparing decorated names

Review found the patch fixed the case that matters least and could hurt the
peer it protects. Both are addressed here.

**The connected peer kept its seal.** `verifiedBadge` already carried
`!peer.isConnected`, so gating it on the rename was dead for a connected peer.
For that peer the seal comes from somewhere else entirely:
`EncryptionStatus.noiseVerified.icon` is the same `checkmark.seal.fill` glyph,
rendered straight from `peer.encryptionStatus.icon` in `MeshPeerList` and again
in the DM header in `ContentSheetViews`. Neither was gated, so a verified key
that renamed while holding a Noise session — the live impersonation — kept a
filled seal beside the new name. The binding now applies at
`ChatPeerIdentityCoordinator.getEncryptionStatus`, which both of those read
through, demoting `.noiseVerified` to `.noiseSecured`: the session really is
encrypted, only the claim about *who* is withdrawn. Nothing branches on
`.noiseVerified` beyond the glyph — every behavioural site already treats it
and `.noiseSecured` alike — so this changes what is drawn and nothing else.

The demotion is computed on read rather than stored, because the cached status
is invalidated by verification and session changes but never by a rename; a
demotion written into the cache would be stale and sticky. It also has to run
on the cache-hit path, which is the common one — the new coordinator test
caught that being missed.

**The comparison could suppress the victim.** `peer.nickname` is already
collision-resolved: `BLEPeerRegistry.transportSnapshots` runs names through
`PeerDisplayNameResolver`, which appends `#` plus four hex of the peerID to
CONNECTED peers whose nicknames collide. That is precisely the attack — two
connected peers both claiming "medic" — so the real medic's `peer.nickname`
becomes "medic#c3d4", no longer equals the pinned "medic", and the impersonated
peer loses its seal exactly when it matters.

So the check no longer takes a name at all. It compares the pinned baseline
against the peer's own announced nickname, both in NFC via `normalizedNickname`
to match how nicknames are compared everywhere else. That removes three
failure modes at once: the collision suffix, the `splitSuffix()` overload that
also stripped every `@` from a legal nickname, and the divergence between a
message row's historical sender name and the live one. A local petname is
handled centrally in the same place, since it outranks the claimed nickname
wherever it is displayed.

Tests: two coordinator cases for the demotion, including that it is not written
into the cache and that it leaves secured and handshaking states alone; a
regression guard asserting `PeerDisplayNameResolver` really does decorate and
that the binding ignores it; petname and NFC cases. 15 in the binding suite,
10 in the coordinator suite.
This commit is contained in:
Shubham Bhandari 2026-09-09 12:02:02 +08:00
parent 37e02c1851
commit a74fa1066e
10 changed files with 226 additions and 132 deletions

View File

@ -165,10 +165,9 @@ final class ConversationUIModel: ObservableObject {
let peerID = message.senderPeerID else { return false }
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
guard chatViewModel.peerIdentityStore.isVerified(fingerprint) else { return false }
// The seal is read next to `message.sender`, so it has to be bound to
// that name: a verified key that renamed itself onto a name the user
// trusts must not carry the seal beside the new one.
return !chatViewModel.trustedNicknameMismatch(fingerprint, displayedSender: message.sender)
// A verified key that renamed itself onto a name the user trusts must
// not carry the seal beside the new one.
return !chatViewModel.trustedNicknameMismatch(fingerprint)
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {

View File

@ -234,13 +234,15 @@ final class PeerListModel: ObservableObject {
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
// A seal is bound to the name it was earned under. Without that,
// a key that earned one vouch as "ravi" can rename itself to
// "medic" and keep rendering the seal beside the new name. A local
// petname already outranks the claimed nickname for display, so
// there is nothing to spoof and the seal stands.
let hasPetname = !(peer.localPetname ?? "").isEmpty
let renamedSinceTrust = !hasPetname && (fingerprint.map {
chatViewModel.trustedNicknameMismatch($0, claimedNickname: peer.nickname)
} ?? false)
// "medic" and keep rendering the seal beside the new name. The
// check compares announced names, NOT `peer.nickname`, which is
// already collision-resolved: two connected peers both claiming
// "medic" are rendered "medic#a1b2" and "medic#c3d4", so comparing
// the displayed string would drop the real medic's seal during the
// very attack this defends against. Petnames are handled there too.
let renamedSinceTrust = fingerprint.map {
chatViewModel.trustedNicknameMismatch($0)
} ?? false
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint && !renamedSinceTrust
// Vouched is subordinate to verified: never show both seals.
let vouchedBadge = !isVerifiedFingerprint && !renamedSinceTrust

View File

@ -130,9 +130,9 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String>
/// Whether this peer is now claiming a different nickname than the one its
/// Whether this peer now announces a different nickname than the one its
/// trust was earned under.
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool
func trustedNicknameMismatch(fingerprint: String) -> Bool
// MARK: Vouching (transitive verification)
@discardableResult
@ -153,17 +153,6 @@ protocol SecureIdentityStateManagerProtocol {
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
}
extension SecureIdentityStateManagerProtocol {
/// `trustedNicknameMismatch` for a name as *rendered* in a message row,
/// which may carry a `#abcd` disambiguation suffix that an announced
/// nickname never has. Comparing the displayed string verbatim would read
/// every suffixed sender as a mismatch.
func trustedNicknameMismatch(fingerprint: String, displayedSender: String) -> Bool {
trustedNicknameMismatch(fingerprint: fingerprint,
claimedNickname: displayedSender.splitSuffix().0)
}
}
/// Singleton manager for secure identity state persistence and retrieval.
/// Provides thread-safe access to identity mappings with encryption at rest.
/// All identity data is stored encrypted in the device Keychain for security.
@ -762,15 +751,31 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
cache.trustedNicknames = pinned
}
/// True only when a baseline exists AND the peer now claims something else.
/// True only when a baseline exists AND the peer now announces something
/// else.
///
/// Deliberately compares announced name to announced name rather than to
/// anything on screen. Display strings are decorated: `PeerDisplayNameResolver`
/// appends `#abcd` to CONNECTED peers whose nicknames collide — which is
/// precisely what happens during this attack — so comparing a rendered name
/// would suppress the seal of the peer being impersonated, exactly when it
/// matters most.
///
/// Fails OPEN on a missing baseline on purpose: peers trusted by builds
/// before this existed have none, and dropping their badges on upgrade
/// would train users to ignore the signal.
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool {
/// before this existed have none, and dropping their seals on upgrade would
/// train users to ignore the signal.
func trustedNicknameMismatch(fingerprint: String) -> Bool {
queue.sync {
guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty,
!claimedNickname.isEmpty else { return false }
return pinned != claimedNickname
guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty
else { return false }
let social = cache.socialIdentities[fingerprint]
// A local petname outranks the claimed nickname everywhere it is
// displayed, so there is nothing to spoof and the seal stands.
if let petname = social?.localPetname, !petname.isEmpty { return false }
guard let claimed = social?.claimedNickname, !claimed.isEmpty else { return false }
// NFC, matching `normalizedNickname` everywhere else nicknames are
// compared: a decomposed and a precomposed "café" are one name.
return pinned.normalizedNickname != claimed.normalizedNickname
}
}

View File

@ -449,9 +449,9 @@ private extension ChatMessageFormatter {
return false
}
guard viewModel.peerIdentityStore.isVerified(fingerprint) else { return false }
// Bound to the rendered name, for the same reason as the peer list and
// the DM seal: the badge attests to a key but is read as a name.
return !viewModel.trustedNicknameMismatch(fingerprint, displayedSender: message.sender)
// Bound to the name the key announces, for the same reason as the peer
// list and the DM seal: the badge attests to a key but is read as a name.
return !viewModel.trustedNicknameMismatch(fingerprint)
}
func appendVerifiedSeal(

View File

@ -76,6 +76,8 @@ protocol ChatPeerIdentityContext: AnyObject {
/// falling back to `fallback` when none was stored. Returns the migrated fingerprint.
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
/// Whether the peer renamed itself since its trust was established.
func trustedNicknameMismatch(_ fingerprint: String) -> Bool
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus?
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID)
@ -437,7 +439,9 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let cachedStatus = context.cachedEncryptionStatus(for: peerID) {
return cachedStatus
// Demote on the cache-hit path too — this is the common one, and
// nothing invalidates the cache on a rename.
return demotedIfRenamed(cachedStatus, for: peerID)
}
// The status must reflect the LIVE session, never history. The old
@ -462,7 +466,26 @@ final class ChatPeerIdentityCoordinator {
}
context.setCachedEncryptionStatus(status, for: peerID)
return status
return demotedIfRenamed(status, for: peerID)
}
/// `.noiseVerified` renders the same filled seal as the verified badge, and
/// for a CONNECTED peer it is the only thing that draws one — so the name
/// binding has to apply here too, or the live impersonation keeps its seal.
///
/// Applied on the way out rather than stored: the cached status is only
/// invalidated by verification and session changes, never by a rename, so a
/// demotion written into the cache would be both stale and sticky. The
/// session really is Noise-secured; only the claim about *who* is weakened,
/// which is exactly the difference between the two cases. Nothing branches
/// on `.noiseVerified` beyond the glyph — every behavioural site treats it
/// and `.noiseSecured` alike.
@MainActor
private func demotedIfRenamed(_ status: EncryptionStatus, for peerID: PeerID) -> EncryptionStatus {
guard status == .noiseVerified,
let fingerprint = getFingerprint(for: peerID),
context.trustedNicknameMismatch(fingerprint) else { return status }
return .noiseSecured
}
@MainActor

View File

@ -1973,27 +1973,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
identityManager.isVouched(fingerprint: fingerprint)
}
/// Whether this peer now claims a different nickname than the one its
/// trust was established under. Trust badges are suppressed in that case:
/// the seal attests to a key, but it is read next to a name, and a key that
/// renamed itself onto a name the user trusts is the impersonation this
/// warns about.
/// Whether this peer now announces a different nickname than the one its
/// trust was established under. Every seal is suppressed in that case: the
/// seal attests to a key, but it is read as a name, and a key that renamed
/// itself onto a name the user trusts is the impersonation this catches.
@MainActor
func trustedNicknameMismatch(_ fingerprint: String, claimedNickname: String) -> Bool {
identityManager.trustedNicknameMismatch(
fingerprint: fingerprint,
claimedNickname: claimedNickname
)
}
/// The same check for a name as *rendered* in a message row. See the
/// `displayedSender` overload on `SecureIdentityStateManagerProtocol`.
@MainActor
func trustedNicknameMismatch(_ fingerprint: String, displayedSender: String) -> Bool {
identityManager.trustedNicknameMismatch(
fingerprint: fingerprint,
displayedSender: displayedSender
)
func trustedNicknameMismatch(_ fingerprint: String) -> Bool {
identityManager.trustedNicknameMismatch(fingerprint: fingerprint)
}
// MARK: - BitchatDelegate Methods

View File

@ -144,6 +144,12 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
verifiedFingerprintSet.contains(fingerprint)
}
var renamedSinceTrust: Set<String> = []
func trustedNicknameMismatch(_ fingerprint: String) -> Bool {
renamedSinceTrust.contains(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
encryptionStatuses[peerID] = status
}
@ -331,6 +337,52 @@ struct ChatPeerIdentityCoordinatorContextTests {
#expect(context.unreadPrivateMessages.isEmpty)
}
@Test @MainActor
func getEncryptionStatus_demotesToSecuredWhenTheKeyRenamedSinceTrust() async {
// `.noiseVerified` renders the same filled seal as the verified badge,
// and for a CONNECTED peer it is the only thing that draws one. A
// verified key that renames itself onto a trusted name must therefore
// lose it here too, or the live impersonation keeps its seal.
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.noiseSessionStates[peerID] = .established
context.fingerprintsByPeerID[peerID] = "fp"
context.verifiedFingerprintSet = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
// The rename lands. Nothing invalidates the encryption cache on a
// nickname change, so the demotion has to survive the cached value.
context.renamedSinceTrust = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseSecured,
"still encrypted, no longer a claim about who")
#expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified,
"the demotion is computed on read, never written into the cache")
// Re-verifying under the new name restores it.
context.renamedSinceTrust = []
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
}
@Test @MainActor
func getEncryptionStatus_leavesUnverifiedStatusesAlone() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// A secured-but-unverified session has no seal to lose, and a rename
// must not disturb the lock it does show.
context.noiseSessionStates[peerID] = .established
context.fingerprintsByPeerID[peerID] = "fp"
context.renamedSinceTrust = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseSecured)
coordinator.invalidateEncryptionCache(for: peerID)
context.noiseSessionStates[peerID] = .handshaking
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking)
}
@Test @MainActor
func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async {
let context = MockChatPeerIdentityContext()

View File

@ -100,10 +100,12 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
var trustedNicknames: [String: String] = [:]
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool {
var claimedNicknames: [String: String] = [:]
func trustedNicknameMismatch(fingerprint: String) -> Bool {
guard let pinned = trustedNicknames[fingerprint], !pinned.isEmpty,
!claimedNickname.isEmpty else { return false }
return pinned != claimedNickname
let claimed = claimedNicknames[fingerprint], !claimed.isEmpty else { return false }
return pinned != claimed
}
func isVerified(fingerprint: String) -> Bool {

View File

@ -1,13 +1,14 @@
import Foundation
import BitFoundation
import Testing
@testable import bitchat
/// Binds a trust badge to the nickname it was earned under.
/// Binds a trust seal to the nickname it was earned under.
///
/// `VouchAttestation` signs `voucheeFingerprint | voucheeSigningKey |
/// timestampMs` and deliberately says nothing about a name — a name-free
/// attestation is the right wire format. But the badge is *rendered* beside a
/// attestation is the right wire format. But the seal is *rendered* beside a
/// self-claimed nickname, so the binding has to live on the receiver, which is
/// the only party that knows what name the key was presenting when it decided
/// to trust it. These tests pin that rule.
@ -37,18 +38,24 @@ struct SecureIdentityStateManagerNicknameBindingTests {
)
}
private func setPetname(_ manager: SecureIdentityStateManager,
_ fingerprint: String,
_ petname: String?) {
guard var identity = manager.getSocialIdentity(for: fingerprint) else { return }
identity.localPetname = petname
manager.updateSocialIdentity(identity)
}
// MARK: - The attack this closes
@Test
func vouch_pinsTheNicknameItWasEarnedUnder() {
func vouchPinsTheNicknameItWasEarnedUnder() {
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: voucher, verified: true)
#expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"))
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"),
"the baseline is pinned: any other name is a mismatch")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
@ -58,14 +65,11 @@ struct SecureIdentityStateManagerNicknameBindingTests {
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
// Same key, new self-chosen name — the badge must not follow it.
announce(manager, vouchee, as: "medic")
#expect(manager.isVouched(fingerprint: vouchee),
"the vouch itself is still valid; only its binding to a name broke")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
"the baseline stayed on the name the vouch was earned under")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
@ -76,12 +80,10 @@ struct SecureIdentityStateManagerNicknameBindingTests {
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
announce(manager, vouchee, as: "medic")
// A second vouch arriving after the rename must not launder it.
manager.setVerified(fingerprint: secondVoucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: secondVoucher, timestamp: Date())
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee),
"the second vouch did not launder the rename")
}
@ -95,21 +97,20 @@ struct SecureIdentityStateManagerNicknameBindingTests {
let manager = makeManager()
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"nothing is bound yet, so nothing can mismatch")
announce(manager, vouchee, as: "ravi")
announce(manager, vouchee, as: "medic")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
"the first name seen while the vouch stood is the binding")
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
func theAnnouncePathDoesNotBindBeforeTrustExists() {
// Otherwise a peer who renamed BEFORE being vouched would be bound to
// the name we happened to see first, and lose a legitimate badge.
// the name we happened to see first, and lose a legitimate seal.
let manager = makeManager()
announce(manager, vouchee, as: "rav")
announce(manager, vouchee, as: "ravi")
@ -117,9 +118,8 @@ struct SecureIdentityStateManagerNicknameBindingTests {
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"bound to the name it was vouched under, not the name seen first")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "rav"))
}
@Test
@ -134,10 +134,67 @@ struct SecureIdentityStateManagerNicknameBindingTests {
announce(manager, vouchee, as: "medic")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"),
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"known gap, pinned here so it is explicit rather than a surprise")
}
// MARK: - Not fooled by display decoration
@Test
func theCheckIsUnaffectedByCollisionSuffixes() {
// `PeerDisplayNameResolver` renders two CONNECTED peers who both claim
// "medic" as "medic#a1b2" and "medic#c3d4" — which is exactly what
// happens during this attack. Comparing a rendered name would drop the
// real medic's seal at the worst possible moment, so the check reads
// announced names only. This pins that the resolver really does
// decorate, and that the binding ignores it.
let real = PeerDisplayNameResolver.resolve(
[(peerID: PeerID(str: "a1b2c3d4"), nickname: "medic", isConnected: true),
(peerID: PeerID(str: "c3d4e5f6"), nickname: "medic", isConnected: true)],
selfNickname: "me")
#expect(real[PeerID(str: "a1b2c3d4")] == "medic#a1b2", "the resolver does decorate")
let manager = makeManager()
announce(manager, vouchee, as: "medic")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "medic") // still "medic" on the wire
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"the impersonated peer keeps its seal while a namesake is connected")
}
@Test
func aLocalPetnameKeepsTheSeal() {
// A petname outranks the claimed nickname everywhere it is displayed,
// so a rename cannot spoof anything and the seal stands.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
setPetname(manager, vouchee, "my neighbour")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
setPetname(manager, vouchee, nil)
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
func namesAreComparedInCanonicalForm() {
// Same rule as `normalizedNickname` everywhere else: a decomposed and a
// precomposed "café" are one name, not a rename.
let manager = makeManager()
announce(manager, vouchee, as: "cafe\u{0301}") // e + combining acute
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "caf\u{00E9}") // precomposed é
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
announce(manager, vouchee, as: "cafe")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
}
// MARK: - Rebinding and clearing
@Test
@ -147,13 +204,12 @@ struct SecureIdentityStateManagerNicknameBindingTests {
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
// The user scanned this key themselves, under the name it shows now.
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
"the baseline moved to the name just checked in person")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
@ -162,71 +218,39 @@ struct SecureIdentityStateManagerNicknameBindingTests {
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee))
manager.setVerified(fingerprint: vouchee, verified: false)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"),
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee),
"with no trust left there is no baseline, so nothing to mismatch")
// With a vouch still standing, the baseline has to survive: it is what
// that vouch's badge is bound to.
// that vouch's seal is bound to.
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: voucher, verified: true)
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
manager.setVerified(fingerprint: vouchee, verified: true)
manager.setVerified(fingerprint: vouchee, verified: false)
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"),
"the vouch's badge still needs the name it was bound to")
}
// MARK: - Names as rendered in a message row
@Test
func aDisambiguationSuffixIsNotMistakenForARename() {
// Message senders render as "ravi#a1b2" when nicknames collide; the
// announced nickname never carries the suffix.
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi#a1b2"))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "@ravi#a1b2"))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi"))
}
@Test
func aRenameIsStillCaughtThroughTheSuffix() {
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "medic#a1b2"))
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "medic"))
}
@Test
func aNameEndingInSomethingSuffixLikeIsNotTruncated() {
// "#abcd" only counts as a suffix when those four characters are hex;
// a nickname that merely contains '#' must still compare whole.
let manager = makeManager()
announce(manager, vouchee, as: "ravi#zzzz")
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi#zzzz"))
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, displayedSender: "ravi"))
announce(manager, vouchee, as: "medic")
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee),
"the vouch's seal still needs the name it was bound to")
}
// MARK: - Failing open
@Test
func aMissingBaselineNeverSuppressesABadge() {
func aMissingBaselineNeverSuppressesASeal() {
// Verified before the peer ever announced a name — there is nothing to
// bind to, and pinning "" would read as a mismatch against every later
// announce. Peers trusted by builds before this shipped land here too,
// so an upgrade must not silently drop their badges.
// so an upgrade must not silently drop their seals.
let manager = makeManager()
manager.setVerified(fingerprint: vouchee, verified: true)
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"))
announce(manager, vouchee, as: "medic")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
}
@Test
@ -234,8 +258,9 @@ struct SecureIdentityStateManagerNicknameBindingTests {
let manager = makeManager()
announce(manager, vouchee, as: "ravi")
manager.setVerified(fingerprint: vouchee, verified: true)
announce(manager, vouchee, as: "")
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: ""))
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee))
}
// MARK: - Persistence compatibility

View File

@ -291,7 +291,7 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
// No name binding is exercised here: these tests drive peer-list assembly,
// not trust display.
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { false }
func trustedNicknameMismatch(fingerprint: String) -> Bool { false }
// MARK: Vouching (unused by these tests)