// // SecureIdentityStateManager.swift // bitchat // // This is free and unencumbered software released into the public domain. // For more information, see // /// /// # SecureIdentityStateManager /// /// Manages the persistent storage and retrieval of identity mappings with /// encryption at rest. This singleton service maintains the relationship between /// ephemeral peer IDs, cryptographic fingerprints, and social identities. /// /// ## Overview /// The SecureIdentityStateManager provides a secure, privacy-preserving way to /// maintain identity relationships across app launches. It implements: /// - Encrypted storage of identity mappings /// - In-memory caching for performance /// - Thread-safe access patterns /// - Automatic debounced persistence /// /// ## Architecture /// The manager operates at three levels: /// 1. **In-Memory State**: Fast access to active identities /// 2. **Encrypted Cache**: Persistent storage in Keychain /// 3. **Privacy Controls**: User-configurable persistence settings /// /// ## Security Features /// /// ### Encryption at Rest /// - Identity cache encrypted with AES-GCM /// - Unique 256-bit encryption key per device /// - Key stored separately in Keychain /// - No plaintext identity data on disk /// /// ### Privacy by Design /// - Persistence is optional (user-controlled) /// - Minimal data retention /// - No cloud sync or backup /// - Automatic cleanup of stale entries /// /// ### Thread Safety /// - Concurrent read access via GCD barriers /// - Write operations serialized /// - Atomic state updates /// - No data races or corruption /// /// ## Data Model /// Manages three types of identity data: /// 1. **Ephemeral Sessions**: Current peer connections /// 2. **Cryptographic Identities**: Public keys and fingerprints /// 3. **Social Identities**: User-assigned names and trust /// /// ## Persistence Strategy /// - Changes batched and debounced (2-second window) /// - Automatic save on app termination /// - Crash-resistant with atomic writes /// - Migration support for schema changes /// /// ## Usage Patterns /// ```swift /// // Register a new peer identity /// manager.registerPeerIdentity(peerID, publicKey, fingerprint) /// /// // Update social identity /// manager.updateSocialIdentity(fingerprint, nickname, trustLevel) /// /// // Query identity /// let identity = manager.resolvePeerIdentity(peerID) /// ``` /// /// ## Performance Optimizations /// - In-memory cache eliminates Keychain roundtrips /// - Debounced saves reduce I/O operations /// - Efficient data structures for lookups /// - Background queue for expensive operations /// /// ## Privacy Considerations /// - Users can disable all persistence /// - Identity cache can be wiped instantly /// - No analytics or telemetry /// - Ephemeral mode for high-risk users /// /// ## Future Enhancements /// - Selective identity export /// - Cross-device identity sync (optional) /// - Identity attestation support /// - Advanced conflict resolution /// import Foundation import CryptoKit protocol SecureIdentityStateManagerProtocol { // MARK: Secure Loading/Saving func forceSave() // MARK: Social Identity Management func getSocialIdentity(for fingerprint: String) -> SocialIdentity? // MARK: Cryptographic Identities func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] func updateSocialIdentity(_ identity: SocialIdentity) // MARK: Favorites Management func getFavorites() -> Set func setFavorite(_ fingerprint: String, isFavorite: Bool) func isFavorite(fingerprint: String) -> Bool // MARK: Blocked Users Management func isBlocked(fingerprint: String) -> Bool func setBlocked(_ fingerprint: String, isBlocked: Bool) // MARK: Geohash (Nostr) Blocking func isNostrBlocked(pubkeyHexLowercased: String) -> Bool func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) func getBlockedNostrPubkeys() -> Set // MARK: Ephemeral Session Management func registerEphemeralSession(peerID: String, handshakeState: HandshakeState) func updateHandshakeState(peerID: String, state: HandshakeState) // MARK: Cleanup func clearAllIdentityData() func removeEphemeralSession(peerID: String) // MARK: Verification func setVerified(fingerprint: String, verified: Bool) func isVerified(fingerprint: String) -> Bool func getVerifiedFingerprints() -> Set } /// Singleton manager for secure identity state persistence and retrieval. /// Provides thread-safe access to identity mappings with encryption at rest. /// All identity data is stored encrypted in the device Keychain for security. final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol { private let keychain: KeychainManagerProtocol private let cacheKey = "bitchat.identityCache.v2" private let encryptionKeyName = "identityCacheEncryptionKey" // In-memory state private var ephemeralSessions: [String: EphemeralIdentity] = [:] private var cryptographicIdentities: [String: CryptographicIdentity] = [:] private var cache: IdentityCache = IdentityCache() // Thread safety private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) // Debouncing for keychain saves private var saveTimer: Timer? private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds private var pendingSave = false // Encryption key private let encryptionKey: SymmetricKey init(_ keychain: KeychainManagerProtocol) { self.keychain = keychain // Generate or retrieve encryption key from keychain let loadedKey: SymmetricKey // Try to load from keychain if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) { loadedKey = SymmetricKey(data: keyData) SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true) } // Generate new key if needed else { loadedKey = SymmetricKey(size: .bits256) let keyData = loadedKey.withUnsafeBytes { Data($0) } // Save to keychain let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName) SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved) } self.encryptionKey = loadedKey // Load identity cache on init loadIdentityCache() } deinit { forceSave() } // MARK: - Secure Loading/Saving private func loadIdentityCache() { guard let encryptedData = keychain.getIdentityKey(forKey: cacheKey) else { // No existing cache, start fresh return } do { let sealedBox = try AES.GCM.SealedBox(combined: encryptedData) let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey) cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData) } catch { // Log error but continue with empty cache SecureLogger.error(error, context: "Failed to load identity cache", category: .security) } } private func saveIdentityCache() { // Mark that we need to save pendingSave = true // Cancel any existing timer saveTimer?.invalidate() // Schedule a new save after the debounce interval saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in self?.performSave() } } private func performSave() { guard pendingSave else { return } pendingSave = false do { let data = try JSONEncoder().encode(cache) let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) if saved { SecureLogger.debug("Identity cache saved to keychain", category: .security) } } catch { SecureLogger.error(error, context: "Failed to save identity cache", category: .security) } } // Force immediate save (for app termination) func forceSave() { saveTimer?.invalidate() performSave() } // MARK: - Social Identity Management func getSocialIdentity(for fingerprint: String) -> SocialIdentity? { queue.sync { return cache.socialIdentities[fingerprint] } } // MARK: - Cryptographic Identities /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. /// - 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) { let now = Date() if var existing = self.cryptographicIdentities[fingerprint] { // Update keys if changed if existing.publicKey != noisePublicKey { existing = CryptographicIdentity( fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey, firstSeen: existing.firstSeen, lastHandshake: now ) self.cryptographicIdentities[fingerprint] = existing } else { // Update signing key and lastHandshake existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey let updated = CryptographicIdentity( fingerprint: existing.fingerprint, publicKey: existing.publicKey, signingPublicKey: existing.signingPublicKey, firstSeen: existing.firstSeen, lastHandshake: now ) self.cryptographicIdentities[fingerprint] = updated } // Persist updated state (already assigned in branches above) } else { // New entry let entry = CryptographicIdentity( fingerprint: fingerprint, publicKey: noisePublicKey, signingPublicKey: signingPublicKey, firstSeen: now, lastHandshake: now ) self.cryptographicIdentities[fingerprint] = entry } // Optionally persist claimed nickname into social identity if let claimed = claimedNickname { var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: claimed, trustLevel: .unknown, isFavorite: false, isBlocked: false, notes: nil ) // Update claimed nickname if changed if identity.claimedNickname != claimed { identity.claimedNickname = claimed self.cache.socialIdentities[fingerprint] = identity } else if self.cache.socialIdentities[fingerprint] == nil { self.cache.socialIdentities[fingerprint] = identity } } self.saveIdentityCache() } } /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { queue.sync { // Defensive: ensure hex and correct length guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) } } } func updateSocialIdentity(_ identity: SocialIdentity) { queue.async(flags: .barrier) { self.cache.socialIdentities[identity.fingerprint] = identity // Update nickname index if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] { // Remove old nickname from index if changed if existingIdentity.claimedNickname != identity.claimedNickname { self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint) if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true { self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname) } } } // Add new nickname to index if self.cache.nicknameIndex[identity.claimedNickname] == nil { self.cache.nicknameIndex[identity.claimedNickname] = Set() } self.cache.nicknameIndex[identity.claimedNickname]?.insert(identity.fingerprint) // Save to keychain self.saveIdentityCache() } } // MARK: - Favorites Management func getFavorites() -> Set { queue.sync { let favorites = cache.socialIdentities.values .filter { $0.isFavorite } .map { $0.fingerprint } return Set(favorites) } } func setFavorite(_ fingerprint: String, isFavorite: Bool) { queue.async(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isFavorite = isFavorite self.cache.socialIdentities[fingerprint] = identity } else { // Create new social identity for this fingerprint let newIdentity = SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Unknown", trustLevel: .unknown, isFavorite: isFavorite, isBlocked: false, notes: nil ) self.cache.socialIdentities[fingerprint] = newIdentity } self.saveIdentityCache() } } func isFavorite(fingerprint: String) -> Bool { queue.sync { return cache.socialIdentities[fingerprint]?.isFavorite ?? false } } // MARK: - Blocked Users Management func isBlocked(fingerprint: String) -> Bool { queue.sync { return cache.socialIdentities[fingerprint]?.isBlocked ?? false } } func setBlocked(_ fingerprint: String, isBlocked: Bool) { SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) queue.async(flags: .barrier) { if var identity = self.cache.socialIdentities[fingerprint] { identity.isBlocked = isBlocked if isBlocked { identity.isFavorite = false // Can't be both favorite and blocked } self.cache.socialIdentities[fingerprint] = identity } else { // Create new social identity for this fingerprint let newIdentity = SocialIdentity( fingerprint: fingerprint, localPetname: nil, claimedNickname: "Unknown", trustLevel: .unknown, isFavorite: false, isBlocked: isBlocked, notes: nil ) self.cache.socialIdentities[fingerprint] = newIdentity } self.saveIdentityCache() } } // MARK: - Geohash (Nostr) Blocking func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { queue.sync { return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) } } func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { let key = pubkeyHexLowercased.lowercased() queue.async(flags: .barrier) { if isBlocked { self.cache.blockedNostrPubkeys.insert(key) } else { self.cache.blockedNostrPubkeys.remove(key) } self.saveIdentityCache() } } func getBlockedNostrPubkeys() -> Set { queue.sync { cache.blockedNostrPubkeys } } // MARK: - Ephemeral Session Management func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) { queue.async(flags: .barrier) { self.ephemeralSessions[peerID] = EphemeralIdentity( peerID: peerID, sessionStart: Date(), handshakeState: handshakeState ) } } func updateHandshakeState(peerID: String, state: HandshakeState) { queue.async(flags: .barrier) { self.ephemeralSessions[peerID]?.handshakeState = state // If handshake completed, update last interaction if case .completed(let fingerprint) = state { self.cache.lastInteractions[fingerprint] = Date() self.saveIdentityCache() } } } // MARK: - Cleanup func clearAllIdentityData() { SecureLogger.warning("Clearing all identity data", category: .security) queue.async(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) } } func removeEphemeralSession(peerID: String) { queue.async(flags: .barrier) { self.ephemeralSessions.removeValue(forKey: peerID) } } // MARK: - Verification func setVerified(fingerprint: String, verified: Bool) { SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) queue.async(flags: .barrier) { if verified { self.cache.verifiedFingerprints.insert(fingerprint) } else { self.cache.verifiedFingerprints.remove(fingerprint) } // Update trust level if social identity exists if var identity = self.cache.socialIdentities[fingerprint] { identity.trustLevel = verified ? .verified : .casual self.cache.socialIdentities[fingerprint] = identity } self.saveIdentityCache() } } func isVerified(fingerprint: String) -> Bool { queue.sync { return cache.verifiedFingerprints.contains(fingerprint) } } func getVerifiedFingerprints() -> Set { queue.sync { return cache.verifiedFingerprints } } }