mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 03:45:20 +00:00
- Remove duplicate NostrIdentityBridge and Bech32 from NostrIdentity.swift (now in separate files) - Add caching to NostrIdentityBridge.deriveIdentity() for performance - Remove duplicate NotificationStreamAssembler from BLEService.swift - Remove duplicate function declarations in BLEService.swift - Remove duplicate DeliveryStatusView and PaymentChipView from ContentView.swift - Fix PeerID type conversions throughout (use .id for String, PeerID(str:) for wrapping) - Update ContentView body to use main's simple VStack structure - Fix NostrIdentityBridge instance method calls - Remove privateChatView (replaced with sheet-based UI in main) Build and tests passing (137/139 tests pass).
158 lines
6.4 KiB
Swift
158 lines
6.4 KiB
Swift
import Foundation
|
|
import CryptoKit
|
|
|
|
/// Bridge between Noise and Nostr identities
|
|
final class NostrIdentityBridge {
|
|
private let keychainService = "chat.bitchat.nostr"
|
|
private let currentIdentityKey = "nostr-current-identity"
|
|
private let deviceSeedKey = "nostr-device-seed"
|
|
// In-memory cache to avoid transient keychain access issues
|
|
private var deviceSeedCache: Data?
|
|
// Cache derived identities to avoid repeated crypto during view rendering
|
|
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
|
private let cacheLock = NSLock()
|
|
|
|
private let keychain: KeychainHelperProtocol
|
|
|
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
|
self.keychain = keychain
|
|
}
|
|
|
|
/// Get or create the current Nostr identity
|
|
func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
|
// Check if we already have a Nostr identity
|
|
if let existingData = keychain.load(key: currentIdentityKey, service: keychainService),
|
|
let identity = try? JSONDecoder().decode(NostrIdentity.self, from: existingData) {
|
|
return identity
|
|
}
|
|
|
|
// Generate new Nostr identity
|
|
let nostrIdentity = try NostrIdentity.generate()
|
|
|
|
// Store it
|
|
let data = try JSONEncoder().encode(nostrIdentity)
|
|
keychain.save(key: currentIdentityKey, data: data, service: keychainService, accessible: nil)
|
|
|
|
return nostrIdentity
|
|
}
|
|
|
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
|
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
if let data = nostrPubkey.data(using: .utf8) {
|
|
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
|
}
|
|
}
|
|
|
|
/// Get Nostr public key associated with a Noise public key
|
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
|
guard let data = keychain.load(key: key, service: keychainService),
|
|
let pubkey = String(data: data, encoding: .utf8) else {
|
|
return nil
|
|
}
|
|
return pubkey
|
|
}
|
|
|
|
/// Clear all Nostr identity associations and current identity
|
|
func clearAllAssociations() {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: keychainService,
|
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
|
kSecReturnAttributes as String: true
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
|
for item in items {
|
|
var deleteQuery: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: keychainService
|
|
]
|
|
if let account = item[kSecAttrAccount as String] as? String {
|
|
deleteQuery[kSecAttrAccount as String] = account
|
|
}
|
|
SecItemDelete(deleteQuery as CFDictionary)
|
|
}
|
|
} else if status == errSecItemNotFound {
|
|
// nothing persisted; no action needed
|
|
}
|
|
|
|
deviceSeedCache = nil
|
|
}
|
|
|
|
// MARK: - Per-Geohash Identities (Location Channels)
|
|
|
|
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
|
/// Stored only on device keychain.
|
|
private func getOrCreateDeviceSeed() -> Data {
|
|
if let cached = deviceSeedCache { return cached }
|
|
if let existing = keychain.load(key: deviceSeedKey, service: keychainService) {
|
|
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
|
|
keychain.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
deviceSeedCache = existing
|
|
return existing
|
|
}
|
|
var seed = Data(count: 32)
|
|
_ = seed.withUnsafeMutableBytes { ptr in
|
|
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
}
|
|
// Ensure availability after first unlock to prevent unintended rotation when locked
|
|
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
|
deviceSeedCache = seed
|
|
return seed
|
|
}
|
|
|
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
|
/// if the candidate is not a valid secp256k1 private key.
|
|
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
|
cacheLock.lock()
|
|
if let cached = derivedIdentityCache[geohash] {
|
|
cacheLock.unlock()
|
|
return cached
|
|
}
|
|
cacheLock.unlock()
|
|
|
|
let seed = getOrCreateDeviceSeed()
|
|
guard let msg = geohash.data(using: .utf8) else {
|
|
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
|
}
|
|
|
|
func candidateKey(iteration: UInt32) -> Data {
|
|
var input = Data(msg)
|
|
var iterBE = iteration.bigEndian
|
|
withUnsafeBytes(of: &iterBE) { bytes in
|
|
input.append(contentsOf: bytes)
|
|
}
|
|
let code = HMAC<SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
|
return Data(code)
|
|
}
|
|
|
|
// Try a few iterations to ensure a valid key can be formed
|
|
for i in 0..<10 {
|
|
let keyData = candidateKey(iteration: UInt32(i))
|
|
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
|
// Cache the result
|
|
cacheLock.lock()
|
|
derivedIdentityCache[geohash] = identity
|
|
cacheLock.unlock()
|
|
return identity
|
|
}
|
|
}
|
|
// As a final fallback, hash the seed+msg and try again
|
|
let fallback = (seed + msg).sha256Hash()
|
|
let identity = try NostrIdentity(privateKeyData: fallback)
|
|
|
|
// Cache the result
|
|
cacheLock.lock()
|
|
derivedIdentityCache[geohash] = identity
|
|
cacheLock.unlock()
|
|
|
|
return identity
|
|
}
|
|
}
|