mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
Bind a trust seal to the nickname it was earned under
A vouched or verified key can rename itself onto a nickname the user trusts and keep rendering the seal beside the new name. `VouchAttestation.signableBytes` covers `voucheeFingerprint | voucheeSigningKey | timestampMs` and says nothing about a name — which is right; the attestation should stay name-free. But the seal is *rendered* beside a self-claimed nickname: `PeerListModel` derives `verifiedBadge`/`vouchedBadge` from the fingerprint and then shows `peer.displayName`, which falls back to the peer-claimed `nickname` whenever `localPetname` is unset. So the seal is read next to a name that nothing binds it to. Earn one genuine vouch as "ravi", announce "medic", and every device that trusts the real medic now shows a second trusted-looking medic. This is the mirror of #1661's case. That one is same nickname, new key; this is same key, new nickname. The binding has to live on the receiver, because the receiver is the only party that knows which name the key was presenting when it decided to trust it. So `IdentityCache` gains `trustedNicknames`, pinned when trust is established, and all three places that render a seal — the peer list, the public-timeline seal and the DM seal — suppress it while the peer presents a different name. Where it is pinned, and why: - On the FIRST vouch only, never moved by a later one. Otherwise an attacker renames and the next voucher silently re-anchors the seal to the new name. - Re-verifying DOES move it: the user just checked this key in person, under whatever name it shows now. - On the first announce seen while trust already stands. A vouch normally arrives for a peer we have not seen announce — it comes over Noise from someone else, and the vouchee may be hops away — so there is no name to bind to at record time. The announce path deliberately does NOT pin before trust exists: binding to whatever name we happened to see first would suppress the seal of a peer who renamed *before* being vouched. - Never on an empty nickname, and a missing baseline never suppresses. Peers trusted by earlier builds have none, and dropping their seals on upgrade would teach users to ignore the signal. Each seal site compares against the name it actually renders, not against the stored `claimedNickname`. That matters: offline favourite rows take their name from the persisted favourite relationship, which can lag the last announce, and a message row shows the sender as of that message. Comparing the rendered string keeps the seal truthful about the name the user is looking at. Message senders can carry a `#abcd` disambiguation suffix that an announced nickname never has, so a `displayedSender` overload strips it with the existing `splitSuffix()`. That overload is a protocol extension rather than a new requirement, so it needs no mock changes and its logic sits in a layer the tests can reach. A local petname already outranks the claimed nickname for display, so there is nothing to spoof there and the seal stands. The known limit, pinned by a test so it is explicit: if we never saw the key under its real name, its first announce IS the baseline, so an impersonating first announce is not caught. Closing that needs the nickname signed into the attestation, which is a wire change and deliberately not attempted here. `FingerprintView`'s vouched panel is left alone: that sheet is an explicit inspection surface keyed by fingerprint, where showing the trust state is the point. No wire or protocol change, and no new user-facing strings. Suppressing the seal is the security fix; explaining it in the UI wants the notice plumbing #1661 is adding, so it is left for a follow-up. `trustedNicknames` decodes with `decodeIfPresent` like the other post-v1 fields, so caches written by older builds still load. Tests: SecureIdentityStateManagerNicknameBindingTests (15) covers the rename attack, the second-voucher laundering attempt, the vouch-before-first-announce path and its residual gap, the no-pin-before-trust rule, suffix handling in both directions, rebinding on re-verification, clearing on unverify only when no vouch remains, both fail-open cases, and persistence compatibility each way. `TestIdentityManager` in UnifiedPeerServiceTests also conforms to the protocol and is updated.
This commit is contained in:
parent
9b84b36122
commit
37e02c1851
@ -164,7 +164,11 @@ final class ConversationUIModel: ObservableObject {
|
||||
!isSentByCurrentUser(message),
|
||||
let peerID = message.senderPeerID else { return false }
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||
return chatViewModel.peerIdentityStore.isVerified(fingerprint)
|
||||
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)
|
||||
}
|
||||
|
||||
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||
|
||||
@ -232,9 +232,18 @@ final class PeerListModel: ObservableObject {
|
||||
let isMe = peer.peerID == myPeerID
|
||||
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||
// 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)
|
||||
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint && !renamedSinceTrust
|
||||
// Vouched is subordinate to verified: never show both seals.
|
||||
let vouchedBadge = !isVerifiedFingerprint
|
||||
let vouchedBadge = !isVerifiedFingerprint && !renamedSinceTrust
|
||||
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||
|
||||
return MeshPeerRow(
|
||||
|
||||
@ -202,6 +202,15 @@ struct IdentityCache: Codable {
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
/// The self-claimed nickname each fingerprint was presenting at the moment
|
||||
/// trust in it was established here — set on verification and on a first
|
||||
/// accepted vouch. A trust badge means "this key was the Medic I checked",
|
||||
/// so it has to be pinned to the name it was earned under; without this
|
||||
/// baseline a vouched key can rename itself onto a name the user trusts and
|
||||
/// keep rendering the seal beside it. Absent for peers trusted by older
|
||||
/// builds, which is why a missing entry never suppresses a badge.
|
||||
var trustedNicknames: [String: String]? = nil
|
||||
|
||||
// Stable Noise fingerprints that proved encrypted private-media support
|
||||
// inside an authenticated Noise session. Optional for decoding caches
|
||||
// written before this migration. Entries are monotonic until a panic wipe
|
||||
@ -240,6 +249,7 @@ struct IdentityCache: Codable {
|
||||
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
|
||||
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
|
||||
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
|
||||
trustedNicknames = try container.decodeIfPresent([String: String].self, forKey: .trustedNicknames)
|
||||
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
|
||||
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
|
||||
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||
|
||||
@ -130,6 +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
|
||||
/// trust was earned under.
|
||||
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
@discardableResult
|
||||
@ -150,6 +153,17 @@ 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.
|
||||
@ -428,6 +442,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
} else if self.cache.socialIdentities[fingerprint] == nil {
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
// A vouch usually arrives for a peer we have not seen announce
|
||||
// — it comes over Noise from someone else, and the vouchee may
|
||||
// be several hops away. There was no name to bind to then, so
|
||||
// bind on the first announce we see while the trust stands.
|
||||
self.pinTrustedNicknameLocked(
|
||||
fingerprint: fingerprint,
|
||||
overwrite: false,
|
||||
requireExistingTrust: true
|
||||
)
|
||||
}
|
||||
|
||||
self.saveIdentityCache()
|
||||
@ -678,9 +701,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
verifiedAt[fingerprint] = Date()
|
||||
self.cache.verifiedAt = verifiedAt
|
||||
// Re-verifying overwrites the baseline: the user just checked
|
||||
// this key again, under whatever name it presents now.
|
||||
self.pinTrustedNicknameLocked(fingerprint: fingerprint, overwrite: true)
|
||||
} else {
|
||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||
if self.cache.vouchesByVouchee?[fingerprint]?.isEmpty ?? true {
|
||||
self.cache.trustedNicknames?.removeValue(forKey: fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// Update trust level if social identity exists
|
||||
@ -698,6 +727,52 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
return cache.verifiedFingerprints.contains(fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Nickname binding
|
||||
//
|
||||
// A vouch signs `voucheeFingerprint | voucheeSigningKey | timestampMs` and
|
||||
// deliberately says nothing about a name — the attestation is right to stay
|
||||
// name-free. But the badge is *rendered* next to a self-claimed nickname,
|
||||
// so the binding has to exist somewhere, and the receiver is the only party
|
||||
// that can hold it: it is the one that decided to trust this key while it
|
||||
// was presenting a particular name.
|
||||
|
||||
/// Requires `queue`. Records the peer's currently claimed nickname as the
|
||||
/// name its trust is bound to. Empty nicknames are not pinned — a key
|
||||
/// verified before its first announce has no name to bind to, and pinning
|
||||
/// "" would then read as a mismatch against every later announce.
|
||||
///
|
||||
/// `requireExistingTrust` is for the announce path, which runs for every
|
||||
/// peer: pinning a name before there is any trust would bind the badge to
|
||||
/// whatever name we happened to see first, so a peer who renamed *before*
|
||||
/// being vouched would have its legitimate badge suppressed.
|
||||
private func pinTrustedNicknameLocked(fingerprint: String,
|
||||
overwrite: Bool,
|
||||
requireExistingTrust: Bool = false) {
|
||||
if requireExistingTrust {
|
||||
let trusted = cache.verifiedFingerprints.contains(fingerprint)
|
||||
|| !(cache.vouchesByVouchee?[fingerprint] ?? []).isEmpty
|
||||
guard trusted else { return }
|
||||
}
|
||||
guard let claimed = cache.socialIdentities[fingerprint]?.claimedNickname,
|
||||
!claimed.isEmpty else { return }
|
||||
var pinned = cache.trustedNicknames ?? [:]
|
||||
if !overwrite, pinned[fingerprint] != nil { return }
|
||||
pinned[fingerprint] = claimed
|
||||
cache.trustedNicknames = pinned
|
||||
}
|
||||
|
||||
/// True only when a baseline exists AND the peer now claims something else.
|
||||
/// 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 {
|
||||
queue.sync {
|
||||
guard let pinned = cache.trustedNicknames?[fingerprint], !pinned.isEmpty,
|
||||
!claimedNickname.isEmpty else { return false }
|
||||
return pinned != claimedNickname
|
||||
}
|
||||
}
|
||||
|
||||
func getVerifiedFingerprints() -> Set<String> {
|
||||
queue.sync {
|
||||
@ -760,6 +835,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
var vouches = self.cache.vouchesByVouchee ?? [:]
|
||||
vouches[voucheeFingerprint] = capped
|
||||
self.cache.vouchesByVouchee = vouches
|
||||
// Pin on the FIRST vouch only. A later vouch for the same key must
|
||||
// not move the baseline, or an attacker could rename and then have
|
||||
// a second voucher silently re-anchor the badge to the new name.
|
||||
self.pinTrustedNicknameLocked(fingerprint: voucheeFingerprint, overwrite: false)
|
||||
self.saveIdentityCache()
|
||||
return true
|
||||
}
|
||||
|
||||
@ -448,7 +448,10 @@ private extension ChatMessageFormatter {
|
||||
let fingerprint = viewModel.getFingerprint(for: peerID) else {
|
||||
return false
|
||||
}
|
||||
return viewModel.peerIdentityStore.isVerified(fingerprint)
|
||||
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)
|
||||
}
|
||||
|
||||
func appendVerifiedSeal(
|
||||
|
||||
@ -1973,6 +1973,29 @@ 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.
|
||||
@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
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - BitchatDelegate Methods
|
||||
|
||||
// MARK: - Command Handling
|
||||
|
||||
@ -98,6 +98,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func setVerified(fingerprint: String, verified: Bool) {}
|
||||
|
||||
var trustedNicknames: [String: String] = [:]
|
||||
|
||||
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool {
|
||||
guard let pinned = trustedNicknames[fingerprint], !pinned.isEmpty,
|
||||
!claimedNickname.isEmpty else { return false }
|
||||
return pinned != claimedNickname
|
||||
}
|
||||
|
||||
func isVerified(fingerprint: String) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
/// Binds a trust badge 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
|
||||
/// 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.
|
||||
///
|
||||
/// `@MainActor` for the same reason as `SecureIdentityStateManagerVouchTests`:
|
||||
/// the manager's blocking `queue.sync` reads must stay off the Swift
|
||||
/// Concurrency cooperative pool, or CI's few-core runners deadlock.
|
||||
@MainActor
|
||||
struct SecureIdentityStateManagerNicknameBindingTests {
|
||||
private let voucher = String(repeating: "0a", count: 32)
|
||||
private let secondVoucher = String(repeating: "0c", count: 32)
|
||||
private let vouchee = String(repeating: "0b", count: 32)
|
||||
|
||||
private func makeManager() -> SecureIdentityStateManager {
|
||||
SecureIdentityStateManager(MockKeychain())
|
||||
}
|
||||
|
||||
/// Mirrors the announce path: the peer tells us what it calls itself.
|
||||
private func announce(_ manager: SecureIdentityStateManager,
|
||||
_ fingerprint: String,
|
||||
as nickname: String) {
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: Data(repeating: 0x01, count: 32),
|
||||
signingPublicKey: nil,
|
||||
claimedNickname: nickname
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - The attack this closes
|
||||
|
||||
@Test
|
||||
func vouch_pinsTheNicknameItWasEarnedUnder() {
|
||||
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")
|
||||
}
|
||||
|
||||
@Test
|
||||
func renamingOntoATrustedNameAfterEarningAVouchIsAMismatch() {
|
||||
let manager = makeManager()
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
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")
|
||||
}
|
||||
|
||||
@Test
|
||||
func aLaterVoucherCannotReanchorTheBaselineToTheNewName() {
|
||||
let manager = makeManager()
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
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"),
|
||||
"the second vouch did not launder the rename")
|
||||
}
|
||||
|
||||
// MARK: - Vouches for peers we have not seen yet
|
||||
|
||||
@Test
|
||||
func aVouchForAnUnseenPeerBindsOnTheirFirstAnnounce() {
|
||||
// The usual case: the vouch arrives over Noise from someone else and
|
||||
// the vouchee is not in our announce set yet, so there is no name to
|
||||
// bind to at record time.
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
|
||||
"nothing is bound yet, so nothing can mismatch")
|
||||
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
announce(manager, vouchee, as: "medic")
|
||||
|
||||
#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")
|
||||
}
|
||||
|
||||
@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.
|
||||
let manager = makeManager()
|
||||
announce(manager, vouchee, as: "rav")
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"),
|
||||
"bound to the name it was vouched under, not the name seen first")
|
||||
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "rav"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func aFirstAnnounceThatIsAlreadyTheImpersonatingNameIsNotCaught() {
|
||||
// The documented limit of receiver-side binding: if we never saw this
|
||||
// key under its real name, the impersonating name IS the baseline.
|
||||
// Only signing the nickname into the attestation closes this, which is
|
||||
// a wire change and deliberately not attempted here.
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
|
||||
announce(manager, vouchee, as: "medic")
|
||||
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"),
|
||||
"known gap, pinned here so it is explicit rather than a surprise")
|
||||
}
|
||||
|
||||
// MARK: - Rebinding and clearing
|
||||
|
||||
@Test
|
||||
func verifyingInPersonRebindsToTheCurrentName() {
|
||||
let manager = makeManager()
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
manager.setVerified(fingerprint: voucher, verified: true)
|
||||
manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
|
||||
announce(manager, vouchee, as: "medic")
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
@Test
|
||||
func unverifyingClearsTheBaselineOnlyWhenNoVouchRemains() {
|
||||
let manager = makeManager()
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
|
||||
manager.setVerified(fingerprint: vouchee, verified: true)
|
||||
#expect(manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
|
||||
manager.setVerified(fingerprint: vouchee, verified: false)
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"),
|
||||
"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.
|
||||
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"))
|
||||
}
|
||||
|
||||
// MARK: - Failing open
|
||||
|
||||
@Test
|
||||
func aMissingBaselineNeverSuppressesABadge() {
|
||||
// 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.
|
||||
let manager = makeManager()
|
||||
manager.setVerified(fingerprint: vouchee, verified: true)
|
||||
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "medic"))
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: "ravi"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func anEmptyClaimedNicknameIsNotAMismatch() {
|
||||
let manager = makeManager()
|
||||
announce(manager, vouchee, as: "ravi")
|
||||
manager.setVerified(fingerprint: vouchee, verified: true)
|
||||
|
||||
#expect(!manager.trustedNicknameMismatch(fingerprint: vouchee, claimedNickname: ""))
|
||||
}
|
||||
|
||||
// MARK: - Persistence compatibility
|
||||
|
||||
@Test
|
||||
func aCacheWrittenBeforeThisFieldExistedStillLoads() throws {
|
||||
let legacyJSON = Data("""
|
||||
{"socialIdentities":{},"nicknameIndex":{},"verifiedFingerprints":["\(vouchee)"],\
|
||||
"lastInteractions":{},"blockedNostrPubkeys":[],"cryptographicIdentities":{},"version":1}
|
||||
""".utf8)
|
||||
|
||||
let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON)
|
||||
|
||||
#expect(decoded.trustedNicknames == nil)
|
||||
#expect(decoded.verifiedFingerprints.contains(vouchee))
|
||||
}
|
||||
|
||||
@Test
|
||||
func theBaselineSurvivesAnEncodeDecodeRoundTrip() throws {
|
||||
var cache = IdentityCache()
|
||||
cache.trustedNicknames = [vouchee: "ravi"]
|
||||
|
||||
let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache))
|
||||
|
||||
#expect(decoded.trustedNicknames?[vouchee] == "ravi")
|
||||
}
|
||||
}
|
||||
@ -289,6 +289,10 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
verified
|
||||
}
|
||||
|
||||
// No name binding is exercised here: these tests drive peer-list assembly,
|
||||
// not trust display.
|
||||
func trustedNicknameMismatch(fingerprint: String, claimedNickname: String) -> Bool { false }
|
||||
|
||||
// MARK: Vouching (unused by these tests)
|
||||
|
||||
@discardableResult
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user