mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:05:19 +00:00
A broad audit surfaced ten critical/high issues across the crypto, transport, identity, and panic-wipe layers. This fixes all ten. Critical: - Nostr DMs were unauthenticated. The NIP-17 seal was signed with a throwaway ephemeral key and the receiver never verified it, so anyone who knows a recipient's npub could forge messages (and delivery/read receipts) into an existing trusted conversation. The seal is now signed with the sender's real identity key, and the receiver verifies the seal signature and that seal.pubkey == rumor.pubkey. NOTE: this is a breaking wire-protocol change (see PR). - Public BLE messages trusted registry membership instead of the packet signature. Since senderID is attacker-controlled, any verified peer could be impersonated in public chat. A valid signature from the claimed sender is now required before any registry identity is used. - Unverified announces still persisted the announced identity, letting a replayed noisePublicKey overwrite a victim's stored signing key and nickname. persistIdentity is now gated on verification. High: - Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix length after nonce extraction) — a remote crash. Now validated. - Identity-cache debounce save used Timer.scheduledTimer on a GCD queue with no run loop, so it never fired; block/verify/favorite changes only persisted on explicit forceSave. Replaced with a DispatchSourceTimer on the queue; forceSave is now serialized. - Identity-cache key load couldn't tell "missing" from a transient keychain failure and would regenerate (deleting) the key, orphaning the cache. Now uses getIdentityKeyWithResult and falls back to a session-only ephemeral key without clobbering the persisted key/cache. - BLE receive-dedup key lacked a payload digest, so post-handshake flushes (queued msgs + delivery/read acks in the same ms) were dropped as duplicates. Digest added, matching the ingress registry. - Maintenance timer was created only in init and never recreated after a panic stop/start, silently degrading the mesh until app restart. Now recreated in startServices. - Panic wipe left persisted location state (selected channel, teleport set, bookmarks) and cached per-geohash Nostr private keys behind. Both are now cleared. - Panic spawned an orphan NostrRelayManager instead of reusing .shared, splitting relay state from every other component. Now reuses .shared. Tests updated to assert the fixed behavior (announce no longer persists unverified identities; public messages require a signature; receive dedup ID includes the payload digest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
6.8 KiB
Swift
166 lines
6.8 KiB
Swift
import BitFoundation
|
|
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: KeychainManagerProtocol
|
|
|
|
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
|
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
|
|
// Also drop the in-memory derived per-geohash identities. These hold the
|
|
// actual secp256k1 private keys; if left cached, post-panic geohash
|
|
// messages would still be signed with pre-panic keys (linkable across the
|
|
// wipe) until the app is force-quit.
|
|
cacheLock.lock()
|
|
derivedIdentityCache.removeAll()
|
|
cacheLock.unlock()
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|