mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:45:20 +00:00
The TOFU signing-key pin lived only in the in-memory peerRegistry. When a previously seen peer was no longer in the registry (app restart, or reconcileConnectivity pruning an offline peer), the announce trust check saw no pinned key, treated the announce as first contact, and persistIdentity overwrote the cached signing key/nickname — so an attacker could replay a victim's noiseKey/peerID with their own signing key and bypass the spoofing protection for returning/offline peers. Fixes: - BLEAnnounceHandler now falls back to the persisted cryptographic identity (via a new persistedSigningPublicKey environment closure) when the registry has no signing key for the peer, so the pin remains effective across registry eviction and app restarts. BLEService wires it to SecureIdentityStateManager.getCryptoIdentitiesByPeerIDPrefix, the same synchronous identity-manager read already used on the packet path by signedSenderDisplayName. - SecureIdentityStateManager.upsertCryptographicIdentity refuses to replace a persisted signing key with a different one (security-logged, nickname update included in the refusal), mirroring the registry policy. First-writer-wins persistence also removes any race where a concurrent announce could poison the stored identity. - CryptographicIdentity entries (incl. the signing-key pin) are now part of the encrypted IdentityCache, so they actually persist across app restarts; previously they were in-memory only. Old caches without the new field still decode (decodeIfPresent), and clearAllIdentityData / panic wipe clears the pins as before. Legitimate re-keying: presenting a different signing key for the same noise key is exactly the attack being blocked, so refusal is correct and permanent until the peer adopts a new noise identity (new peerID) or the user explicitly clears identity data. Repeated rejected announces follow the existing unverified-announce path (log + ignore); no retry loops or crashes. Tests: handler-level persisted-pin mismatch/match/precedence cases, an end-to-end restart+eviction test with real Ed25519 keys and a real SecureIdentityStateManager showing the attacker replay is rejected and the persisted identity is untouched while the victim re-announce is accepted, and identity-manager tests for the pinned-key refusal and the keychain round-trip of the pin. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
594 lines
23 KiB
Swift
594 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)
|
|
}
|
|
}
|