diff --git a/.gitignore b/.gitignore index df91a858..ac9e4d89 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,4 @@ build.log # Local configs Local.xcconfig +*.profraw diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 0d8dc475..1213b762 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -161,24 +161,24 @@ struct VouchRecord: Codable, Equatable { struct IdentityCache: Codable { // Fingerprint -> Social mapping var socialIdentities: [String: SocialIdentity] = [:] - + // Nickname -> [Fingerprints] reverse index // Multiple fingerprints can claim same nickname var nicknameIndex: [String: Set] = [:] - + // Verified fingerprints (cryptographic proof) var verifiedFingerprints: Set = [] - + // Last interaction timestamps (privacy: optional) - var lastInteractions: [String: Date] = [:] - + var lastInteractions: [String: Date] = [:] + // Blocked Nostr pubkeys (lowercased hex) for geohash chats var blockedNostrPubkeys: Set = [] // Vouching (transitive verification). All three fields are Optional so - // caches persisted before this feature decode cleanly — the synthesized - // decoder uses decodeIfPresent for optionals, and a missing key must not - // trip the "unreadable cache" recovery path that discards everything. + // caches persisted before this feature decode cleanly — decodeIfPresent + // is used below, and a missing key must not trip the "unreadable cache" + // recovery path that discards everything. // Vouchee fingerprint -> accepted vouches (capped per vouchee) var vouchesByVouchee: [String: [VouchRecord]]? = nil @@ -189,6 +189,36 @@ struct IdentityCache: Codable { // Fingerprint -> when we verified it (orders outgoing vouch batches; // entries verified before this field exists sort as oldest) var verifiedAt: [String: Date]? = nil + + // Fingerprint -> Cryptographic identity (noise + pinned signing key). + // Persisting the signing-key pin is security-critical: it must survive + // app restarts so an attacker cannot replay a known peer's + // noiseKey/peerID with their own signing key and be treated as first + // contact (TOFU downgrade). + var cryptographicIdentities: [String: CryptographicIdentity] = [:] + + // Schema version for future migrations + var version: Int = 1 + + init() {} + + // Custom decoding so caches written by older builds (missing newer keys + // such as `cryptographicIdentities` or the vouching fields) still load + // instead of being discarded. Every field uses decodeIfPresent so a + // missing key falls back to its default rather than throwing. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:] + nicknameIndex = try container.decodeIfPresent([String: Set].self, forKey: .nicknameIndex) ?? [:] + verifiedFingerprints = try container.decodeIfPresent(Set.self, forKey: .verifiedFingerprints) ?? [] + lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:] + blockedNostrPubkeys = try container.decodeIfPresent(Set.self, forKey: .blockedNostrPubkeys) ?? [] + 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) + cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:] + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + } } // diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 7c68a01b..341319b0 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -152,18 +152,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // In-memory state private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] - private var cryptographicIdentities: [String: CryptographicIdentity] = [:] + // Cryptographic identities (including pinned signing keys) live inside + // `cache` so they persist across app restarts; see IdentityCache. private var cache: IdentityCache = IdentityCache() // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) // Pending-save coalescing flag. Reads/writes are serialized on `queue`. - // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather - // than a retained DispatchSourceTimer: a lingering, never-cancelled timer - // keeps the dispatch machinery alive and prevents the unit-test process from - // exiting. (The original code used Timer.scheduledTimer on a GCD queue with - // no run loop, so saves never actually fired.) + // + // Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt + // + keychain write inside `queue.sync(flags: .barrier)`, so when the call + // returns the write is already complete and NOTHING is left scheduled on + // the queue. This is deliberate — a retained DispatchSourceTimer (the + // original design) kept the dispatch machinery alive and prevented the + // unit-test process from exiting, and fire-and-forget `queue.async(.barrier)` + // (a later design) left a backlog of instrumented barrier saves still + // draining when LLVM's `--enable-code-coverage` `atexit` handler dumped + // `.profraw`, deadlocking the process at teardown on the constrained CI + // runner. Synchronous persistence has zero outstanding dispatch at exit, so + // neither failure mode is possible. `pendingSave` is now effectively always + // false after any mutation (saveIdentityCache persists inline and clears + // it); it remains only as a belt-and-suspenders flag read by `forceSave` + // and `deinit`. private var pendingSave = false // Encryption key @@ -223,7 +234,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } deinit { - forceSave() + // Do NOT dispatch onto `queue` here. `deinit` can run on any thread + // (including one draining `queue`), and the object is being + // deallocated: a `queue.sync` risks a re-entrant same-queue wait + // (deadlock) and a `queue.async` schedules work that resurrects `self` + // and may not drain before process exit. + // + // A flush here is redundant anyway: every mutating API already + // persists inline within its own barrier, so the keychain is already + // up to date. As a queue-free best-effort belt-and-suspenders, only + // flush if something is still pending. This is a direct read of + // in-hand state — safe because a deallocating object has no other + // live references, so nothing can be mutating `cache` concurrently. + if pendingSave { + pendingSave = false + persist(snapshot: cache) + } } // MARK: - Secure Loading/Saving @@ -248,21 +274,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - /// Persists the cache. Always invoked on `queue` under a barrier (its callers - /// run inside `queue.async(.barrier)`), so it simply marks the cache dirty - /// and persists it on the same serialized context — no timer, nothing left - /// scheduled to keep the process alive. + /// Persists the cache. Always invoked on `queue` under a barrier (its + /// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read + /// while serialized. The encode + keychain write are done here (already on + /// the exclusive barrier context), synchronously, so no separate hop is + /// scheduled and nothing is left to keep the process alive. private func saveIdentityCache() { pendingSave = true - performSave() + // On the barrier context already: snapshot is trivially consistent. + persist(snapshot: cache) + pendingSave = false } - /// Writes the cache to the keychain. Must run on `queue` with exclusive - /// (barrier) access. - private func performSave() { - guard pendingSave else { return } - pendingSave = false - + /// Encodes, seals, and writes a *snapshot* of the cache to the keychain. + /// + /// Takes the cache by value so callers can capture a consistent snapshot + /// under `queue` and then encode without holding it. Reading `cache` + /// concurrently with a barrier writer would be a data race on the + /// dictionary storage, which — because `JSONEncoder` walks that storage — + /// can spin forever (observed as a CI test-suite hang), so the snapshot + /// must be taken on `queue`, never off it. + private func persist(snapshot: IdentityCache) { // Never persist under an ephemeral key — it would overwrite the real // cache with data the next launch cannot decrypt. guard !encryptionKeyIsEphemeral else { @@ -271,7 +303,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } do { - let data = try JSONEncoder().encode(cache) + let data = try JSONEncoder().encode(snapshot) let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) if saved { @@ -282,14 +314,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } } - // Force immediate save (for app termination / lifecycle events). Mutations - // already persist synchronously via saveIdentityCache, so this is normally a - // no-op (performSave early-returns when nothing is pending). Runs directly on - // the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is - // reachable from `deinit` and from async tests on the swift-concurrency - // cooperative pool where a blocking barrier-sync can starve/deadlock it. + // Force a flush (for app-termination / lifecycle events — NOT from + // `deinit`, which persists inline; see the deinit note). Every mutating + // API already persists inline inside its own barrier via + // `saveIdentityCache`, so by the time this is called the keychain is + // already up to date and this is normally a no-op; it exists as a + // belt-and-suspenders flush of any `pendingSave` left set. + // + // Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier + // makes the `cache` read race-free (a plain off-queue read races in-flight + // barrier writers — JSONEncoder walking a concurrently-mutated dictionary + // can spin forever, which surfaced as a CI hang), and being synchronous it + // leaves nothing scheduled to keep the process alive at teardown. Safe + // against re-entrant deadlock because this is never invoked from `deinit` + // (the only path that can run *on* `queue`). func forceSave() { - performSave() + queue.sync(flags: .barrier) { + guard pendingSave else { return } + pendingSave = false + persist(snapshot: cache) + } } // MARK: - Social Identity Management @@ -303,15 +347,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { // MARK: - Cryptographic Identities /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. + /// + /// TOFU signing-key pinning: once a signing key has been persisted for a + /// fingerprint, an update carrying a *different* signing key is refused in + /// full (including the claimed-nickname update) and security-logged. This + /// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an + /// attacker replaying a victim's noiseKey/peerID with their own signing + /// key could overwrite the victim's persisted identity while the victim is + /// offline or after an app restart. The refusal is permanent: there is + /// currently no targeted in-app way to reset the pin (`setVerified` does + /// not touch it). Recovering from a legitimate signing re-key requires the + /// peer to establish a new noise identity (new peerID) or the local user + /// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe). /// - Parameters: /// - fingerprint: SHA-256 hex of the Noise static public key /// - noisePublicKey: Noise static public key data /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - claimedNickname: Optional latest claimed nickname to persist into social identity func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { let now = Date() - if var existing = self.cryptographicIdentities[fingerprint] { + if var existing = self.cache.cryptographicIdentities[fingerprint] { + if let pinnedSigningKey = existing.signingPublicKey, + let announcedSigningKey = signingPublicKey, + pinnedSigningKey != announcedSigningKey { + SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security) + return + } // Update keys if changed if existing.publicKey != noisePublicKey { existing = CryptographicIdentity( @@ -320,11 +382,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { signingPublicKey: signingPublicKey ?? existing.signingPublicKey, firstSeen: existing.firstSeen ) - self.cryptographicIdentities[fingerprint] = existing + self.cache.cryptographicIdentities[fingerprint] = existing } else { // Update signing key existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey - self.cryptographicIdentities[fingerprint] = existing + self.cache.cryptographicIdentities[fingerprint] = existing } // Persist updated state (already assigned in branches above) } else { @@ -335,7 +397,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { signingPublicKey: signingPublicKey, firstSeen: now ) - self.cryptographicIdentities[fingerprint] = entry + self.cache.cryptographicIdentities[fingerprint] = entry } // Optionally persist claimed nickname into social identity @@ -367,12 +429,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { queue.sync { // Defensive: ensure hex and correct length guard peerID.isShort else { return [] } - return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } + return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } } } func updateSocialIdentity(_ identity: SocialIdentity) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname self.cache.socialIdentities[identity.fingerprint] = identity @@ -408,7 +470,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func setFavorite(_ fingerprint: String, isFavorite: Bool) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isFavorite = isFavorite self.cache.socialIdentities[fingerprint] = identity @@ -446,7 +508,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setBlocked(_ fingerprint: String, isBlocked: Bool) { SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isBlocked = isBlocked if isBlocked { @@ -480,7 +542,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { let key = pubkeyHexLowercased.lowercased() - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if isBlocked { self.cache.blockedNostrPubkeys.insert(key) } else { @@ -503,7 +565,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func updateHandshakeState(peerID: PeerID, state: HandshakeState) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { self.ephemeralSessions[peerID]?.handshakeState = state // If handshake completed, update last interaction @@ -519,11 +581,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func clearAllIdentityData() { SecureLogger.warning("Clearing all identity data", category: .security) - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { self.cache = IdentityCache() self.ephemeralSessions.removeAll() - self.cryptographicIdentities.removeAll() - + // Delete from keychain let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted) @@ -531,7 +592,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { } func removeEphemeralSession(peerID: PeerID) { - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { self.ephemeralSessions.removeValue(forKey: peerID) } } @@ -541,7 +602,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { func setVerified(fingerprint: String, verified: Bool) { SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) - queue.async(flags: .barrier) { + queue.sync(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) var verifiedAt = self.cache.verifiedAt ?? [:] @@ -709,7 +770,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { /// The peer's announce-bound Ed25519 signing key, if seen this session. func signingPublicKey(forFingerprint fingerprint: String) -> Data? { - queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey } + queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey } } /// Verified fingerprints ordered most recently verified first (entries diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift index 306831ce..10b27f42 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandler.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -14,8 +14,14 @@ struct BLEAnnounceHandlerEnvironment { let messageTTL: UInt8 /// Current time source. let now: () -> Date - /// Noise public key already recorded for the peer, if any (registry read). - let existingNoisePublicKey: (PeerID) -> Data? + /// Noise and signing public keys already recorded for the peer, if any + /// (single registry read so both come from one consistent snapshot). + let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?) + /// Signing key from the persisted cryptographic identity for the peer, if + /// any. Registry pins do not survive app restarts or offline-peer + /// eviction; this fallback keeps the TOFU signing-key pin effective for + /// returning peers. + let persistedSigningPublicKey: (PeerID) -> Data? /// Verifies the packet signature against the announced signing key. let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool /// Direct link state for the peer (BLE-queue read). @@ -29,13 +35,15 @@ struct BLEAnnounceHandlerEnvironment { /// Runs the registry mutation phase under the collections barrier. let withRegistryBarrier: (() -> Void) -> Void /// Upserts the verified announce into the peer registry. + /// Returns `nil` when the registry refuses the announce because it carries + /// a signing key different from the one already pinned for this peer. /// Must only be called from inside `withRegistryBarrier`. let upsertVerifiedAnnounce: ( _ peerID: PeerID, _ announcement: AnnouncementPacket, _ isConnected: Bool, _ now: Date - ) -> BLEPeerAnnounceUpdate + ) -> BLEPeerAnnounceUpdate? /// Debounced reconnect-log decision. /// Must only be called from inside `withRegistryBarrier`. let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool @@ -115,7 +123,16 @@ final class BLEAnnounceHandler { // Suppress announce logs to reduce noise // Precompute signature verification outside barrier to reduce contention - let existingNoisePublicKey = env.existingNoisePublicKey(peerID) + var existingPeerKeys = env.existingPeerKeys(peerID) + if existingPeerKeys.signingPublicKey == nil { + // The registry entry (and its signing-key pin) is dropped on app + // restart and offline-peer eviction, but the persisted + // cryptographic identity survives both. Fall back to it so a + // returning peer is not treated as first contact — otherwise an + // attacker could replay the peer's noiseKey/peerID with their own + // signing key and re-pin the identity (TOFU downgrade). + existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID) + } let hasSignature = packet.signature != nil let signatureValid: Bool if hasSignature { @@ -129,13 +146,18 @@ final class BLEAnnounceHandler { let trustDecision = BLEAnnounceTrustPolicy.evaluate( hasSignature: hasSignature, signatureValid: signatureValid, - existingNoisePublicKey: existingNoisePublicKey, - announcedNoisePublicKey: announcement.noisePublicKey + existingNoisePublicKey: existingPeerKeys.noisePublicKey, + announcedNoisePublicKey: announcement.noisePublicKey, + existingSigningPublicKey: existingPeerKeys.signingPublicKey, + announcedSigningPublicKey: announcement.signingPublicKey ) if case .reject(.keyMismatch) = trustDecision { SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) } - let verifiedAnnounce = trustDecision.isVerified + if case .reject(.signingKeyMismatch) = trustDecision { + SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security) + } + var verifiedAnnounce = trustDecision.isVerified var isNewPeer = false var isReconnectedPeer = false @@ -172,12 +194,22 @@ final class BLEAnnounceHandler { return } - let update = env.upsertVerifiedAnnounce( + // The registry re-checks the signing-key pin inside the barrier. + // The pre-barrier trust check reads the registry outside the + // barrier, so this closes the race where two announces for the + // same peer are evaluated concurrently. + guard let update = env.upsertVerifiedAnnounce( peerID, announcement, hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), now - ) + ) else { + SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security) + verifiedAnnounce = false + isNewPeer = false + isReconnectedPeer = false + return + } isNewPeer = update.isNewPeer isReconnectedPeer = update.wasDisconnected diff --git a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift index 9e81d9ce..03c80325 100644 --- a/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift +++ b/bitchat/Services/BLE/BLEAnnounceHandlingPolicy.swift @@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable { case missingSignature case invalidSignature case keyMismatch + case signingKeyMismatch } enum BLEAnnounceTrustDecision: Equatable { @@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy { hasSignature: Bool, signatureValid: Bool, existingNoisePublicKey: Data?, - announcedNoisePublicKey: Data + announcedNoisePublicKey: Data, + existingSigningPublicKey: Data?, + announcedSigningPublicKey: Data ) -> BLEAnnounceTrustDecision { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { return .reject(.keyMismatch) } + // TOFU signing-key pinning. The packet signature only proves the + // announce is self-consistent — it is verified against the Ed25519 key + // carried *inside the same announce*. Since peerIDs derive from the + // broadcast (public) noise key, an attacker can replay a victim's + // peerID+noiseKey with their own signing key and a valid + // self-signature. Once we have bound a signing key to this peer, + // refuse to silently replace it. + if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey { + return .reject(.signingKeyMismatch) + } + guard hasSignature else { return .reject(.missingSignature) } diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift index 792107d8..80a13831 100644 --- a/bitchat/Services/BLE/BLEPeerRegistry.swift +++ b/bitchat/Services/BLE/BLEPeerRegistry.swift @@ -174,6 +174,14 @@ struct BLEPeerRegistry { peers[peerID] = peer } + /// Applies a verified announce to the registry. + /// + /// TOFU signing-key pinning: once a signing key has been bound to this + /// peer entry, an announce carrying a *different* signing key is refused + /// (returns `nil`) and the existing record is left untouched. PeerIDs are + /// derived from the (public) noise key, so without pinning an attacker + /// could replay a victim's noiseKey/peerID with their own signing key and + /// silently take over the victim's mesh identity and nickname. mutating func upsertVerifiedAnnounce( peerID: PeerID, nickname: String, @@ -183,8 +191,15 @@ struct BLEPeerRegistry { now: Date, capabilities: PeerCapabilities = [], bridgeGeohash: String? = nil - ) -> BLEPeerAnnounceUpdate { + ) -> BLEPeerAnnounceUpdate? { let existing = peers[peerID] + + if let pinnedSigningKey = existing?.signingPublicKey, + let announcedSigningKey = signingPublicKey, + pinnedSigningKey != announcedSigningKey { + return nil + } + let update = BLEPeerAnnounceUpdate( isNewPeer: existing == nil, wasDisconnected: existing?.isConnected == false, @@ -196,7 +211,8 @@ struct BLEPeerRegistry { nickname: nickname, isConnected: isConnected, noisePublicKey: noisePublicKey, - signingPublicKey: signingPublicKey, + // Never drop an already-pinned signing key. + signingPublicKey: signingPublicKey ?? existing?.signingPublicKey, isVerifiedNickname: true, lastSeen: now, capabilities: capabilities, diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index af4bc601..13ea54ad 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -5461,9 +5461,21 @@ extension BLEService { }, messageTTL: messageTTL, now: { Date() }, - existingNoisePublicKey: { [weak self] peerID in + existingPeerKeys: { [weak self] peerID in + guard let self = self else { return (nil, nil) } + return self.collectionsQueue.sync { + let info = self.peerRegistry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + } + }, + persistedSigningPublicKey: { [weak self] peerID in + // Same synchronous identity-manager read pattern as + // signedSenderDisplayName(for:from:); the manager serializes + // access on its own internal queue. guard let self = self else { return nil } - return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey } + return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + .compactMap { $0.signingPublicKey } + .first }, verifySignature: { [weak self] packet, signingPublicKey in self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false @@ -5495,16 +5507,24 @@ extension BLEService { }, upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in // Called from inside withRegistryBarrier; access registry directly. - self?.peerRegistry.upsertVerifiedAnnounce( + guard let self = self else { + return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + } + return self.peerRegistry.upsertVerifiedAnnounce( peerID: peerID, nickname: announcement.nickname, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isConnected: isConnected, + // Propagate `nil` (registry refused the announce because it + // carries a signing key different from the pinned one) so + // the handler's guard rejects it instead of overwriting the + // pinned identity. Main's capabilities/bridgeGeohash are + // preserved. now: now, capabilities: announcement.capabilities ?? [], bridgeGeohash: announcement.bridgeGeohash - ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + ) }, shouldEmitReconnectLog: { [weak self] peerID, now in // Called from inside withRegistryBarrier; access debouncer directly. diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift index 9623573a..7911b4e2 100644 --- a/bitchatTests/Services/BLEAnnounceHandlerTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -6,10 +6,13 @@ import Testing struct BLEAnnounceHandlerTests { private final class Recorder { var existingNoisePublicKey: Data? + var existingSigningPublicKey: Data? + var persistedSigningPublicKey: Data? + var persistedSigningKeyQueries: [PeerID] = [] var signatureValid = true var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkBoundToOtherPeer = false - var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) var dedupSeenIDs: Set = [] var shouldEmitReconnectLogResult = true @@ -36,7 +39,11 @@ struct BLEAnnounceHandlerTests { localPeerID: { localPeerID }, messageTTL: TransportConfig.messageTTLDefault, now: { now }, - existingNoisePublicKey: { _ in recorder.existingNoisePublicKey }, + existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) }, + persistedSigningPublicKey: { peerID in + recorder.persistedSigningKeyQueries.append(peerID) + return recorder.persistedSigningPublicKey + }, verifySignature: { packet, signingPublicKey in recorder.verifySignatureCalls.append((packet, signingPublicKey)) return recorder.signatureValid @@ -482,6 +489,168 @@ struct BLEAnnounceHandlerTests { #expect(recorder.topologyUpdates.first?.neighbors == neighbors) } + @Test + func matchingPinnedSigningKeyIsAccepted() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9A, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = noiseKey + // Matches the signing key encoded by makeAnnouncePacket. + recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.persistedIdentities.count == 1) + } + + @Test + func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9B, count: 32) + let peerID = PeerID(publicKey: noiseKey) + // Attacker announce: victim's noiseKey/peerID, attacker's signing key + // (0x99 from makeAnnouncePacket) with a "valid" self-signature. + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = noiseKey + recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key + recorder.signatureValid = true + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + } + + @Test + func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws { + // Registry has no entry (app restart or offline-peer eviction), but + // the persisted cryptographic identity still pins the victim's + // signing key. An attacker replaying the victim's noiseKey/peerID + // with their own signing key must not be treated as first contact. + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9D, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = nil + recorder.existingSigningPublicKey = nil + recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.persistedSigningKeyQueries == [peerID]) + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + } + + @Test + func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws { + // Legitimate returning peer: registry entry evicted, persisted pin + // matches the announced signing key — accepted like a normal announce. + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9E, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + // Matches the signing key encoded by makeAnnouncePacket. + recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.persistedIdentities.count == 1) + } + + @Test + func registryPinnedSigningKeySkipsPersistedLookup() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9F, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = noiseKey + recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.persistedSigningKeyQueries.isEmpty) + #expect(recorder.upsertCalls.count == 1) + } + + @Test + func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x9C, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64), + directNeighbors: [Data(repeating: 0xAB, count: 8)] + ) + + // Pre-barrier trust check sees no pinned key (e.g. concurrent race), + // but the registry itself refuses to replace its pinned signing key. + let recorder = Recorder() + recorder.upsertResult = nil + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + #expect(recorder.afterglowDelays.isEmpty) + } + @Test func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws { let now = Date(timeIntervalSince1970: 1_000) @@ -506,6 +675,253 @@ struct BLEAnnounceHandlerTests { #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) } + @Test + func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws { + // Real crypto: the attacker crafts a fully self-consistent announce + // (victim's noiseKey/peerID, attacker's signing key and nickname, + // valid packet signature made with the attacker's key). Without + // signing-key pinning this used to overwrite the victim's registry + // entry and persisted identity. + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let attacker = NoiseEncryptionService(keychain: MockKeychain()) + let victimNoiseKey = victim.getStaticPublicKeyData() + let peerID = PeerID(publicKey: victimNoiseKey) + let now = Date() + + final class RegistryBox { + var registry = BLEPeerRegistry() + var persistedIdentities: [AnnouncementPacket] = [] + } + let box = RegistryBox() + + let environment = BLEAnnounceHandlerEnvironment( + localPeerID: { PeerID(str: "0102030405060708") }, + messageTTL: TransportConfig.messageTTLDefault, + now: { now }, + existingPeerKeys: { peerID in + let info = box.registry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + }, + persistedSigningPublicKey: { _ in nil }, + verifySignature: { packet, signingPublicKey in + victim.verifyPacketSignature(packet, publicKey: signingPublicKey) + }, + linkState: { _ in (hasPeripheral: true, hasCentral: false) }, + linkBoundToOtherPeer: { _, _ in false }, + withRegistryBarrier: { body in body() }, + upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in + box.registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + now: now + ) + }, + shouldEmitReconnectLog: { _, _ in false }, + updateTopology: { _, _ in }, + persistIdentity: { announcement in + box.persistedIdentities.append(announcement) + }, + dedupContains: { _ in true }, + dedupMarkProcessed: { _ in }, + deliverAnnounceUIEvents: { _, _, _ in }, + trackPacketSeen: { _ in }, + sendAnnounceBack: {}, + scheduleAfterglow: { _ in } + ) + let handler = BLEAnnounceHandler(environment: environment) + + func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: nickname, + noisePublicKey: victimNoiseKey, + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + return try #require(signer.signPacket(packet)) + } + + // Legitimate announce from the victim is accepted and pinned. + let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim) + handler.handle(victimAnnounce, from: peerID) + + #expect(box.registry.info(for: peerID)?.nickname == "victim") + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(box.persistedIdentities.count == 1) + + // Attacker announce with a valid self-signature must be rejected. + let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker) + handler.handle(attackerAnnounce, from: peerID) + + #expect(box.registry.info(for: peerID)?.nickname == "victim") + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(box.persistedIdentities.count == 1) + + // The victim's subsequent announces (same pinned key) still work. + let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim) + handler.handle(victimRename, from: peerID) + + #expect(box.registry.info(for: peerID)?.nickname == "victim-renamed") + #expect(box.persistedIdentities.count == 2) + } + + @Test + func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws { + // Real crypto + real persistence: the victim announces and gets + // pinned, then the registry entry disappears (offline-peer eviction + // via reconcileConnectivity, or app restart which starts with an + // empty registry). The attacker replays the victim's + // noiseKey/peerID with their own signing key and a valid + // self-signature — the persisted identity must still block the + // takeover, and must not be overwritten. The victim (same signing + // key) must be re-accepted. + let victim = NoiseEncryptionService(keychain: MockKeychain()) + let attacker = NoiseEncryptionService(keychain: MockKeychain()) + let victimNoiseKey = victim.getStaticPublicKeyData() + let peerID = PeerID(publicKey: victimNoiseKey) + let now = Date() + + let identityKeychain = MockKeychain() + let identityManager = SecureIdentityStateManager(identityKeychain) + + final class RegistryBox { + var registry = BLEPeerRegistry() + } + let box = RegistryBox() + + func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment { + BLEAnnounceHandlerEnvironment( + localPeerID: { PeerID(str: "0102030405060708") }, + messageTTL: TransportConfig.messageTTLDefault, + now: { now }, + existingPeerKeys: { peerID in + let info = box.registry.info(for: peerID) + return (info?.noisePublicKey, info?.signingPublicKey) + }, + // Mirrors the BLEService wiring: fall back to the persisted + // cryptographic identity. + persistedSigningPublicKey: { peerID in + identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) + .compactMap { $0.signingPublicKey } + .first + }, + verifySignature: { packet, signingPublicKey in + victim.verifyPacketSignature(packet, publicKey: signingPublicKey) + }, + linkState: { _ in (hasPeripheral: true, hasCentral: false) }, + linkBoundToOtherPeer: { _, _ in false }, + withRegistryBarrier: { body in body() }, + upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in + box.registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + now: now + ) + }, + shouldEmitReconnectLog: { _, _ in false }, + updateTopology: { _, _ in }, + persistIdentity: { announcement in + identityManager.upsertCryptographicIdentity( + fingerprint: announcement.noisePublicKey.sha256Fingerprint(), + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + }, + dedupContains: { _ in true }, + dedupMarkProcessed: { _ in }, + deliverAnnounceUIEvents: { _, _, _ in }, + trackPacketSeen: { _ in }, + sendAnnounceBack: {}, + scheduleAfterglow: { _ in } + ) + } + let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager)) + + func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: nickname, + noisePublicKey: victimNoiseKey, + signingPublicKey: signer.getSigningPublicKeyData(), + directNeighbors: nil + ) + let payload = try #require(announcement.encode()) + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + return try #require(signer.signPacket(packet)) + } + + func persistedIdentity() -> CryptographicIdentity? { + // queue.sync read; fences the manager's pending barrier writes. + identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first + } + + // 1. Victim announces: pinned in the registry and persisted. + handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData()) + + // 2. Registry entry disappears (eviction / restart). + _ = box.registry.remove(peerID) + #expect(box.registry.info(for: peerID) == nil) + + // 3. Attacker replay with own signing key: rejected via the persisted + // pin, and neither the registry nor the persisted identity change. + handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID) + #expect(box.registry.info(for: peerID) == nil) + #expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData()) + #expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim") + + // 4. Victim re-announces with the same signing key: accepted again. + handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) + #expect(box.registry.info(for: peerID)?.nickname == "victim") + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + + // 5. Simulated app restart: a fresh identity manager reloads the pin + // from the (mock) keychain, and a fresh registry starts empty. The + // attacker replay is still rejected. + identityManager.forceSave() + let reloadedManager = SecureIdentityStateManager(identityKeychain) + #expect( + reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey + == victim.getSigningPublicKeyData() + ) + box.registry = BLEPeerRegistry() + let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager)) + restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID) + #expect(box.registry.info(for: peerID) == nil) + #expect( + reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey + == victim.getSigningPublicKeyData() + ) + + // ...while the victim is accepted after the restart. + restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID) + #expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData()) + } + private func expectNoSideEffects(_ recorder: Recorder) { #expect(recorder.barrierCount == 0) #expect(recorder.upsertCalls.isEmpty) diff --git a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift index c0226b81..8004cc48 100644 --- a/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift +++ b/bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift @@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: false, signatureValid: false, existingNoisePublicKey: nil, - announcedNoisePublicKey: Data(repeating: 0x11, count: 32) + announcedNoisePublicKey: Data(repeating: 0x11, count: 32), + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .reject(.missingSignature)) @@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: false, existingNoisePublicKey: nil, - announcedNoisePublicKey: Data(repeating: 0x11, count: 32) + announcedNoisePublicKey: Data(repeating: 0x11, count: 32), + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .reject(.invalidSignature)) @@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: true, existingNoisePublicKey: Data(repeating: 0xAA, count: 32), - announcedNoisePublicKey: Data(repeating: 0xBB, count: 32) + announcedNoisePublicKey: Data(repeating: 0xBB, count: 32), + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .reject(.keyMismatch)) @@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests { hasSignature: true, signatureValid: true, existingNoisePublicKey: noiseKey, - announcedNoisePublicKey: noiseKey + announcedNoisePublicKey: noiseKey, + existingSigningPublicKey: nil, + announcedSigningPublicKey: Data(repeating: 0x99, count: 32) ) #expect(decision == .verified) #expect(decision.isVerified) } + @Test + func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() { + let noiseKey = Data(repeating: 0xCC, count: 32) + + // Attacker replays the victim's noiseKey/peerID with their own signing + // key and a valid self-signature; the pinned key must win. + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey, + existingSigningPublicKey: Data(repeating: 0x99, count: 32), + announcedSigningPublicKey: Data(repeating: 0x66, count: 32) + ) + + #expect(decision == .reject(.signingKeyMismatch)) + #expect(!decision.isVerified) + } + + @Test + func trustPolicyAcceptsMatchingPinnedSigningKey() { + let noiseKey = Data(repeating: 0xCC, count: 32) + let signingKey = Data(repeating: 0x99, count: 32) + + let decision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: true, + signatureValid: true, + existingNoisePublicKey: noiseKey, + announcedNoisePublicKey: noiseKey, + existingSigningPublicKey: signingKey, + announcedSigningPublicKey: signingKey + ) + + #expect(decision == .verified) + } + @Test func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() { let directNew = BLEAnnounceResponsePolicy.plan( diff --git a/bitchatTests/Services/BLEPeerRegistryTests.swift b/bitchatTests/Services/BLEPeerRegistryTests.swift index 01b69eae..2a8188d4 100644 --- a/bitchatTests/Services/BLEPeerRegistryTests.swift +++ b/bitchatTests/Services/BLEPeerRegistryTests.swift @@ -6,12 +6,12 @@ import Testing @Suite("BLE peer registry tests") struct BLEPeerRegistryTests { @Test("upserted announces track new, reconnect, and rename transitions") - func upsertVerifiedAnnounceTracksTransitions() { + func upsertVerifiedAnnounceTracksTransitions() throws { var registry = BLEPeerRegistry() let peerID = PeerID(str: "1122334455667788") let firstSeen = Date(timeIntervalSince1970: 100) - let first = registry.upsertVerifiedAnnounce( + let firstResult = registry.upsertVerifiedAnnounce( peerID: peerID, nickname: "alice", noisePublicKey: Data([1, 2, 3]), @@ -19,6 +19,7 @@ struct BLEPeerRegistryTests { isConnected: true, now: firstSeen ) + let first = try #require(firstResult) #expect(first.isNewPeer) #expect(!first.wasDisconnected) @@ -27,7 +28,7 @@ struct BLEPeerRegistryTests { #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") registry.markDisconnected(peerID) - let reconnect = registry.upsertVerifiedAnnounce( + let reconnectResult = registry.upsertVerifiedAnnounce( peerID: peerID, nickname: "alice-renamed", noisePublicKey: Data([1, 2, 3]), @@ -35,6 +36,7 @@ struct BLEPeerRegistryTests { isConnected: true, now: firstSeen.addingTimeInterval(1) ) + let reconnect = try #require(reconnectResult) #expect(!reconnect.isNewPeer) #expect(reconnect.wasDisconnected) @@ -42,6 +44,85 @@ struct BLEPeerRegistryTests { #expect(registry.info(for: peerID)?.nickname == "alice-renamed") } + @Test("pinned signing key cannot be silently replaced by a later announce") + func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x11, count: 32) + let victimSigningKey = Data(repeating: 0x42, count: 32) + let attackerSigningKey = Data(repeating: 0x66, count: 32) + let firstSeen = Date(timeIntervalSince1970: 100) + + let pinResult = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "victim", + noisePublicKey: noiseKey, + signingPublicKey: victimSigningKey, + isConnected: true, + now: firstSeen + ) + #expect(pinResult != nil) + + // Attacker replays the victim's noiseKey/peerID with their own + // signing key and nickname; the upsert must be refused wholesale. + let attack = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "attacker", + noisePublicKey: noiseKey, + signingPublicKey: attackerSigningKey, + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(attack == nil) + let info = try #require(registry.info(for: peerID)) + #expect(info.nickname == "victim") + #expect(info.signingPublicKey == victimSigningKey) + + // A legitimate re-announce with the pinned key is still accepted. + let legit = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "victim-renamed", + noisePublicKey: noiseKey, + signingPublicKey: victimSigningKey, + isConnected: true, + now: firstSeen.addingTimeInterval(2) + ) + #expect(legit != nil) + #expect(registry.info(for: peerID)?.nickname == "victim-renamed") + } + + @Test("announce without a signing key keeps the pinned key") + func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws { + var registry = BLEPeerRegistry() + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x11, count: 32) + let signingKey = Data(repeating: 0x42, count: 32) + let firstSeen = Date(timeIntervalSince1970: 100) + + let initialResult = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: noiseKey, + signingPublicKey: signingKey, + isConnected: true, + now: firstSeen + ) + #expect(initialResult != nil) + + let update = registry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: "alice", + noisePublicKey: noiseKey, + signingPublicKey: nil, + isConnected: true, + now: firstSeen.addingTimeInterval(1) + ) + + #expect(update != nil) + #expect(registry.info(for: peerID)?.signingPublicKey == signingKey) + } + @Test("reachability keeps recent verified offline peers only when mesh is attached") func reachabilityRequiresMeshAttachmentForOfflinePeers() { let offlinePeer = PeerID(str: "1122334455667788") diff --git a/bitchatTests/Services/SecureIdentityStateManagerTests.swift b/bitchatTests/Services/SecureIdentityStateManagerTests.swift index a280d02e..3679cbcb 100644 --- a/bitchatTests/Services/SecureIdentityStateManagerTests.swift +++ b/bitchatTests/Services/SecureIdentityStateManagerTests.swift @@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase { XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) } + func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async { + let manager = SecureIdentityStateManager(MockKeychain()) + let noisePublicKey = Data(repeating: 0x11, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + let peerID = PeerID(publicKey: noisePublicKey) + let victimSigningKey = Data(repeating: 0x22, count: 32) + let attackerSigningKey = Data(repeating: 0x66, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim" + ) + let pinned = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey + } + XCTAssertTrue(pinned) + + // Attacker upsert with a different signing key must be refused in + // full — signing key AND claimed nickname stay the victim's. + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: attackerSigningKey, + claimedNickname: "attacker" + ) + + // Synchronous reads fence the manager's pending barrier writes. + XCTAssertEqual( + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") + + // The legitimate peer (same signing key) can still update. + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim-renamed" + ) + let renamed = await waitUntil { + manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed" + } + XCTAssertTrue(renamed) + XCTAssertEqual( + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + } + + func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async { + let keychain = MockKeychain() + let manager = SecureIdentityStateManager(keychain) + let noisePublicKey = Data(repeating: 0x13, count: 32) + let fingerprint = noisePublicKey.sha256Fingerprint() + let peerID = PeerID(publicKey: noisePublicKey) + let victimSigningKey = Data(repeating: 0x24, count: 32) + let attackerSigningKey = Data(repeating: 0x77, count: 32) + + manager.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: victimSigningKey, + claimedNickname: "victim" + ) + let pinned = await waitUntil { + manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey + } + XCTAssertTrue(pinned) + manager.forceSave() + + // Simulated app restart: the pin must survive and still refuse a + // different signing key. + let reloaded = SecureIdentityStateManager(keychain) + XCTAssertEqual( + reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + + reloaded.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: noisePublicKey, + signingPublicKey: attackerSigningKey, + claimedNickname: "attacker" + ) + XCTAssertEqual( + reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey, + victimSigningKey + ) + XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim") + } + func test_setBlocked_clearsFavoriteState() async { let manager = SecureIdentityStateManager(MockKeychain()) let fingerprint = String(repeating: "ab", count: 32)