mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
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:
parent
37e02c1851
commit
a74fa1066e
@ -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? {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user