Files
bitchat/bitchatTests/Services/SecureIdentityStateManagerTests.swift
T
jackandClaude Opus 4.8 0558c2bc82 Pin announce signing keys to stop mesh identity spoofing
BLE announce trust verified the packet signature against the Ed25519
signing key carried inside the same announce, and the trust policy only
rejected on noise-key mismatch. Since peerIDs derive from the broadcast
(public) noise key, an on-mesh attacker could replay a victim's
peerID+noiseKey with their own signing key, nickname, and a valid
self-signature; the registry/persisted identity was then overwritten
unconditionally, enabling mesh nickname spoofing and forged attribution
of signed public/broadcast messages.

Fix: TOFU-pin the Ed25519 signing key per peer (noise-key-derived
peerID) on the announce path.

- BLEAnnounceTrustPolicy rejects announces whose signing key differs
  from the one already recorded for the peer (.signingKeyMismatch).
- BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned
  signing key (returns nil) and never drops a pinned key.
- BLEAnnounceHandler falls back to the persisted cryptographic identity
  (persistedSigningPublicKey) when the registry has no signing key, so
  the pin survives registry eviction and app restart.
- SecureIdentityStateManager.upsertCryptographicIdentity refuses to
  replace a persisted signing key with a different one, and the
  cryptographic identities (incl. the pin) now live in the encrypted,
  persisted IdentityCache with synchronous, teardown-safe saves.

Rebased onto main and integrated with #1432 (Noise session identity
binding + signed leaves): both are complementary. #1432's signed-leave
verification reads the same registry/persisted signing key that this
change protects from announce-path poisoning. main's evolution is kept:
BLEPeerRegistry.upsertVerifiedAnnounce still takes capabilities/
bridgeGeohash (nil return propagates as a refusal), the handler's
linkBoundToOtherPeer env is preserved, CryptographicIdentity keeps
main's field set, and EphemeralIdentity uses main's initializer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:06:37 +02:00

598 lines
23 KiB
Swift

import Foundation
import XCTest
import BitFoundation
@testable import bitchat
final class SecureIdentityStateManagerTests: XCTestCase {
func test_upsertCryptographicIdentity_withoutClaimedNicknameDoesNotCreateSocialIdentity() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "aa", count: 32)
let peerID = PeerID(str: String(fingerprint.prefix(16)))
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
claimedNickname: nil
)
let inserted = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).count == 1
}
XCTAssertTrue(inserted)
XCTAssertNil(manager.getSocialIdentity(for: fingerprint))
}
func test_upsertCryptographicIdentity_updatesExistingKeyAndPreservesSigningKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)
let peerID = PeerID(str: String(fingerprint.prefix(16)))
let originalNoiseKey = Data(repeating: 0x11, count: 32)
let updatedNoiseKey = Data(repeating: 0x33, count: 32)
let signingKey = Data(repeating: 0x22, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: originalNoiseKey,
signingPublicKey: signingKey,
claimedNickname: nil
)
_ = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.publicKey == originalNoiseKey
}
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: updatedNoiseKey,
signingPublicKey: nil,
claimedNickname: nil
)
let updated = await waitUntil {
guard let identity = manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first else { return false }
return identity.publicKey == updatedNoiseKey && identity.signingPublicKey == signingKey
}
XCTAssertTrue(updated)
}
func test_upsertCryptographicIdentity_tracksByPeerIDPrefixAndClaimedNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let noisePublicKey = Data(repeating: 0x11, count: 32)
let signingPublicKey = Data(repeating: 0x22, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
claimedNickname: "Alice"
)
let socialIdentityLoaded = await waitUntil {
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice"
}
XCTAssertTrue(socialIdentityLoaded)
let matches = manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(publicKey: noisePublicKey))
XCTAssertEqual(matches.count, 1)
XCTAssertEqual(matches.first?.fingerprint, fingerprint)
XCTAssertEqual(matches.first?.publicKey, noisePublicKey)
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)
manager.setFavorite(fingerprint, isFavorite: true)
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(favoriteSet)
manager.setBlocked(fingerprint, isBlocked: true)
let blockedSet = await waitUntil { manager.isBlocked(fingerprint: fingerprint) }
XCTAssertTrue(blockedSet)
XCTAssertFalse(manager.isFavorite(fingerprint: fingerprint))
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
}
func test_isBlocked_unknownFingerprintReturnsFalse() {
let manager = SecureIdentityStateManager(MockKeychain())
XCTAssertFalse(manager.isBlocked(fingerprint: String(repeating: "ff", count: 32)))
}
func test_setVerified_updatesTrustLevelAndVerifiedSet() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "cd", count: 32)
manager.setFavorite(fingerprint, isFavorite: false)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setVerified(fingerprint: fingerprint, verified: true)
let verifiedSet = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
XCTAssertTrue(verifiedSet)
XCTAssertTrue(manager.getVerifiedFingerprints().contains(fingerprint))
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.trustLevel, .verified)
}
func test_forceSave_persistsFavoriteStateAcrossReinit() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let fingerprint = String(repeating: "ef", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let favoriteSet = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(favoriteSet)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertTrue(reloaded.isFavorite(fingerprint: fingerprint))
}
func test_updateSocialIdentity_reindexesClaimedNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "34", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Alice",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
let initialIndexed = await waitUntil {
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) == true
}
XCTAssertTrue(initialIndexed)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: "Friend",
claimedNickname: "Bob",
trustLevel: .trusted,
isFavorite: true,
isBlocked: false,
notes: "updated"
)
)
let reindexed = await waitUntil {
manager.debugNicknameIndex["Alice"]?.contains(fingerprint) != true &&
manager.debugNicknameIndex["Bob"]?.contains(fingerprint) == true
}
XCTAssertTrue(reindexed)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Bob")
}
func test_upsertCryptographicIdentity_sameClaimedNicknamePreservesExistingSocialIdentity() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "35", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: "Pal",
claimedNickname: "Alice",
trustLevel: .trusted,
isFavorite: true,
isBlocked: false,
notes: "keep me"
)
)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
claimedNickname: "Alice"
)
let inserted = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(PeerID(str: String(fingerprint.prefix(16)))).count == 1
}
XCTAssertTrue(inserted)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.localPetname, "Pal")
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.notes, "keep me")
XCTAssertTrue(manager.getSocialIdentity(for: fingerprint)?.isFavorite == true)
}
func test_getFavorites_returnsOnlyFavoritedFingerprints() async {
let manager = SecureIdentityStateManager(MockKeychain())
let favoriteOne = String(repeating: "45", count: 32)
let favoriteTwo = String(repeating: "56", count: 32)
let other = String(repeating: "67", count: 32)
manager.setFavorite(favoriteOne, isFavorite: true)
manager.setFavorite(favoriteTwo, isFavorite: true)
manager.setFavorite(other, isFavorite: false)
let favoritesLoaded = await waitUntil {
manager.getFavorites() == Set([favoriteOne, favoriteTwo])
}
XCTAssertTrue(favoritesLoaded)
}
func test_setFavorite_existingIdentityCanBeClearedWithoutChangingNickname() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "68", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Alice",
trustLevel: .trusted,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setFavorite(fingerprint, isFavorite: true)
_ = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
manager.setFavorite(fingerprint, isFavorite: false)
let cleared = await waitUntil {
!manager.isFavorite(fingerprint: fingerprint) &&
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "Alice" &&
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .trusted
}
XCTAssertTrue(cleared)
}
func test_setBlocked_createsIdentityAndCanLaterUnblock() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "78", count: 32)
manager.setBlocked(fingerprint, isBlocked: true)
let blocked = await waitUntil {
manager.isBlocked(fingerprint: fingerprint)
}
XCTAssertTrue(blocked)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "Unknown")
manager.setBlocked(fingerprint, isBlocked: false)
let unblocked = await waitUntil {
!manager.isBlocked(fingerprint: fingerprint)
}
XCTAssertTrue(unblocked)
}
func test_setVerified_false_downgradesTrustLevelToCasual() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "89", count: 32)
manager.updateSocialIdentity(
SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "Verifier",
trustLevel: .trusted,
isFavorite: false,
isBlocked: false,
notes: nil
)
)
_ = await waitUntil { manager.getSocialIdentity(for: fingerprint) != nil }
manager.setVerified(fingerprint: fingerprint, verified: true)
_ = await waitUntil { manager.isVerified(fingerprint: fingerprint) }
manager.setVerified(fingerprint: fingerprint, verified: false)
let downgraded = await waitUntil {
!manager.isVerified(fingerprint: fingerprint) &&
manager.getSocialIdentity(for: fingerprint)?.trustLevel == .casual
}
XCTAssertTrue(downgraded)
}
func test_ephemeralSessionLifecycle_tracksHandshakeProgressAndLastInteraction() async {
let manager = SecureIdentityStateManager(MockKeychain())
let peerID = PeerID(str: "1234567890abcdef")
let fingerprint = String(repeating: "90", count: 32)
manager.registerEphemeralSession(peerID: peerID, handshakeState: .initiated)
let registered = await waitUntil {
if case .initiated? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
return true
}
return false
}
XCTAssertTrue(registered)
manager.updateHandshakeState(peerID: peerID, state: .inProgress)
let progressed = await waitUntil {
if case .inProgress? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
return true
}
return false
}
XCTAssertTrue(progressed)
manager.updateHandshakeState(peerID: peerID, state: .completed(fingerprint: fingerprint))
let completed = await waitUntil {
if case .completed(let completedFingerprint)? = manager.debugEphemeralSession(for: peerID)?.handshakeState {
return completedFingerprint == fingerprint && manager.debugLastInteraction(for: fingerprint) != nil
}
return false
}
XCTAssertTrue(completed)
manager.removeEphemeralSession(peerID: peerID)
let removed = await waitUntil {
manager.debugEphemeralSession(for: peerID) == nil
}
XCTAssertTrue(removed)
}
func test_setNostrBlocked_normalizesToLowercaseAndPersists() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let pubkey = "ABCDEF1234"
manager.setNostrBlocked(pubkey, isBlocked: true)
let nostrBlocked = await waitUntil {
manager.isNostrBlocked(pubkeyHexLowercased: pubkey.lowercased())
}
XCTAssertTrue(nostrBlocked)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(reloaded.getBlockedNostrPubkeys(), Set([pubkey.lowercased()]))
XCTAssertTrue(reloaded.isNostrBlocked(pubkeyHexLowercased: pubkey))
}
func test_setNostrBlocked_falseRemovesExistingKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let pubkey = "ABCDEF1234"
manager.setNostrBlocked(pubkey, isBlocked: true)
_ = await waitUntil { manager.isNostrBlocked(pubkeyHexLowercased: pubkey) }
manager.setNostrBlocked(pubkey, isBlocked: false)
let cleared = await waitUntil {
!manager.isNostrBlocked(pubkeyHexLowercased: pubkey) &&
manager.getBlockedNostrPubkeys().isEmpty
}
XCTAssertTrue(cleared)
}
func test_corruptPersistedCache_fallsBackToEmptyState() {
let keychain = MockKeychain()
_ = keychain.saveIdentityKey(Data(repeating: 0x01, count: 32), forKey: "identityCacheEncryptionKey")
_ = keychain.saveIdentityKey(Data([0xFF, 0x00, 0xAA]), forKey: "bitchat.identityCache.v2")
let manager = SecureIdentityStateManager(keychain)
XCTAssertTrue(manager.getFavorites().isEmpty)
XCTAssertTrue(manager.getVerifiedFingerprints().isEmpty)
XCTAssertTrue(manager.getBlockedNostrPubkeys().isEmpty)
XCTAssertNil(keychain.getIdentityKey(forKey: "bitchat.identityCache.v2"))
}
func test_clearAllIdentityData_removesCachedState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "12", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
manager.setVerified(fingerprint: fingerprint, verified: true)
manager.setNostrBlocked("ABCD", isBlocked: true)
let primed = await waitUntil {
manager.isFavorite(fingerprint: fingerprint) &&
manager.isVerified(fingerprint: fingerprint)
}
XCTAssertTrue(primed)
manager.clearAllIdentityData()
let cleared = await waitUntil {
!manager.isFavorite(fingerprint: fingerprint) &&
!manager.isVerified(fingerprint: fingerprint) &&
manager.getBlockedNostrPubkeys().isEmpty
}
XCTAssertTrue(cleared)
}
func test_forceSave_withFailingCacheWriteDoesNotPersistCache() async {
let keychain = FailingCacheSaveKeychain()
let manager = SecureIdentityStateManager(keychain)
let fingerprint = String(repeating: "de", count: 32)
manager.setFavorite(fingerprint, isFavorite: true)
let primed = await waitUntil { manager.isFavorite(fingerprint: fingerprint) }
XCTAssertTrue(primed)
manager.forceSave()
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertFalse(reloaded.isFavorite(fingerprint: fingerprint))
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping () -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return true
}
try? await Task.sleep(nanoseconds: 10_000_000)
}
return condition()
}
}
private final class FailingCacheSaveKeychain: KeychainManagerProtocol {
private var storage: [String: Data] = [:]
private var serviceStorage: [String: [String: Data]] = [:]
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
if key == "bitchat.identityCache.v2" {
return false
}
storage[key] = keyData
return true
}
func getIdentityKey(forKey key: String) -> Data? {
storage[key]
}
func deleteIdentityKey(forKey key: String) -> Bool {
storage.removeValue(forKey: key)
return true
}
func deleteAllKeychainData() -> Bool {
storage.removeAll()
serviceStorage.removeAll()
return true
}
func secureClear(_ data: inout Data) {
data = Data()
}
func secureClear(_ string: inout String) {
string = ""
}
func verifyIdentityKeyExists() -> Bool {
storage["identity_noiseStaticKey"] != nil
}
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
if let data = storage[key] {
return .success(data)
}
return .itemNotFound
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
if saveIdentityKey(keyData, forKey: key) {
return .success
}
return .otherError(OSStatus(-1))
}
func save(key: String, data: Data, service: String, accessible: CFString?) {
if serviceStorage[service] == nil {
serviceStorage[service] = [:]
}
serviceStorage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
serviceStorage[service]?[key]
}
func delete(key: String, service: String) {
serviceStorage[service]?.removeValue(forKey: key)
}
func deleteAll(service: String) {
serviceStorage.removeValue(forKey: service)
}
}