Refactor Nostr ID Bridge & Keychain Helper (#796)

* Extract each type to a separate file

* Nostr ID Bridge: Convert static func/vars to instance

* `KeychainHelper` behind a protocol to easily mock

* Update tests with ID Bridge and MockKeychainHelper

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
This commit is contained in:
Islam
2025-10-14 12:26:18 +02:00
committed by GitHub
co-authored by jack
parent e3149fa098
commit ad4103bacc
18 changed files with 442 additions and 370 deletions
+5 -1
View File
@@ -26,11 +26,15 @@ struct BitchatApp: App {
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
init() {
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain)
)
)
@@ -50,7 +54,7 @@ struct BitchatApp: App {
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS)
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
protocol KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString?)
func load(key: String, service: String) -> Data?
func delete(key: String, service: String)
}
/// Keychain helper for secure storage
struct KeychainHelper: KeychainHelperProtocol {
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
-308
View File
@@ -1,50 +1,5 @@
import Foundation
import CryptoKit
import P256K
import Security
// Keychain helper for secure storage
struct KeychainHelper {
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(key: String, service: String) -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
}
static func delete(key: String, service: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
struct NostrIdentity: Codable {
@@ -103,266 +58,3 @@ struct NostrIdentity: Codable {
return publicKey.hexEncodedString()
}
}
/// Bridge between Noise and Nostr identities
struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private static var deviceSeedCache: Data?
/// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
// Check if we already have a Nostr identity
if let existingData = KeychainHelper.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)
KeychainHelper.save(key: currentIdentityKey, data: data, service: keychainService)
return nostrIdentity
}
/// Associate a Nostr identity with a Noise public key (for favorites)
static func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
KeychainHelper.save(key: key, data: data, service: keychainService)
}
}
/// Get Nostr public key associated with a Noise public key
static func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
guard let data = KeychainHelper.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
static 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 static func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
KeychainHelper.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
KeychainHelper.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.
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
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 = CryptoKit.HMAC<CryptoKit.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) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
}
}
// Bech32 encoding for Nostr (minimal implementation)
enum Bech32 {
private static let charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private static let generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
static func encode(hrp: String, data: Data) throws -> String {
let values = convertBits(from: 8, to: 5, pad: true, data: Array(data))
let checksum = createChecksum(hrp: hrp, values: values)
let combined = values + checksum
return hrp + "1" + combined.map {
let index = charset.index(charset.startIndex, offsetBy: Int($0))
return String(charset[index])
}.joined()
}
static func decode(_ bech32String: String) throws -> (hrp: String, data: Data) {
// Find the last occurrence of '1'
guard let separatorIndex = bech32String.lastIndex(of: "1") else {
throw Bech32Error.invalidFormat
}
let hrp = String(bech32String[..<separatorIndex])
// Validate HRP contains only ASCII characters
for char in hrp {
guard char.asciiValue != nil else {
throw Bech32Error.invalidCharacter
}
}
let dataString = String(bech32String[bech32String.index(after: separatorIndex)...])
// Convert characters to values
var values = [UInt8]()
for char in dataString {
guard let index = charset.firstIndex(of: char) else {
throw Bech32Error.invalidCharacter
}
values.append(UInt8(charset.distance(from: charset.startIndex, to: index)))
}
// Verify checksum
guard values.count >= 6 else {
throw Bech32Error.invalidChecksum
}
let payloadValues = Array(values.dropLast(6))
let checksum = Array(values.suffix(6))
let expectedChecksum = createChecksum(hrp: hrp, values: payloadValues)
guard checksum == expectedChecksum else {
throw Bech32Error.invalidChecksum
}
// Convert back to bytes
let bytes = convertBits(from: 5, to: 8, pad: false, data: payloadValues)
return (hrp: hrp, data: Data(bytes))
}
enum Bech32Error: Error {
case invalidFormat
case invalidCharacter
case invalidChecksum
}
private static func convertBits(from: Int, to: Int, pad: Bool, data: [UInt8]) -> [UInt8] {
var acc = 0
var bits = 0
var result = [UInt8]()
let maxv = (1 << to) - 1
for value in data {
acc = (acc << from) | Int(value)
bits += from
while bits >= to {
bits -= to
result.append(UInt8((acc >> bits) & maxv))
}
}
if pad && bits > 0 {
result.append(UInt8((acc << (to - bits)) & maxv))
}
return result
}
private static func createChecksum(hrp: String, values: [UInt8]) -> [UInt8] {
let checksumValues = hrpExpand(hrp) + values + [0, 0, 0, 0, 0, 0]
let polymod = polymod(checksumValues) ^ 1
var checksum = [UInt8]()
for i in 0..<6 {
checksum.append(UInt8((polymod >> (5 * (5 - i))) & 31))
}
return checksum
}
private static func hrpExpand(_ hrp: String) -> [UInt8] {
var result = [UInt8]()
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue >> 5))
}
result.append(0)
for c in hrp {
guard let asciiValue = c.asciiValue else {
return [] // Return empty array for invalid input
}
result.append(UInt8(asciiValue & 31))
}
return result
}
private static func polymod(_ values: [UInt8]) -> Int {
var chk = 1
for value in values {
let b = chk >> 25
chk = (chk & 0x1ffffff) << 5 ^ Int(value)
for i in 0..<5 {
if (b >> i) & 1 == 1 {
chk ^= generator[i]
}
}
}
return chk
}
}
// Data hex encoding extension moved to BinaryEncodingUtils.swift to avoid duplication
+135
View File
@@ -0,0 +1,135 @@
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?
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 {
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) {
return identity
}
}
// As a final fallback, hash the seed+msg and try again
let fallback = (seed + msg).sha256Hash()
return try NostrIdentity(privateKeyData: fallback)
}
}
+8 -2
View File
@@ -94,6 +94,7 @@ final class BLEService: NSObject {
private var noiseService: NoiseEncryptionService
private let identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
private var myPeerIDData: Data = Data()
// MARK: - Advertising Privacy
@@ -198,8 +199,13 @@ final class BLEService: NSObject {
// MARK: - Initialization
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.keychain = keychain
self.idBridge = idBridge
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init()
@@ -613,7 +619,7 @@ final class BLEService: NSObject {
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
// Add our Nostr public key if available
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
content += ":" + myNostrIdentity.npub
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
}
+1 -1
View File
@@ -104,7 +104,7 @@ final class CommandProcessor {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
private static let storageKey = "chat.bitchat.favorites"
private static let keychainService = "chat.bitchat.favorites"
private let keychain: KeychainHelperProtocol
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
private init() {
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
self.keychain = keychain
loadFavorites()
// Update mutual favorites when favorites change
@@ -196,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
saveFavorites()
// Delete from keychain directly
KeychainHelper.delete(
keychain.delete(
key: Self.storageKey,
service: Self.keychainService
)
@@ -216,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
let data = try encoder.encode(relationships)
// Store in keychain for security
KeychainHelper.save(
keychain.save(
key: Self.storageKey,
data: data,
service: Self.keychainService
service: Self.keychainService,
accessible: nil
)
// Successfully saved favorites
@@ -231,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
private func loadFavorites() {
// Loading favorites from keychain
guard let data = KeychainHelper.load(
guard let data = keychain.load(
key: Self.storageKey,
service: Self.keychainService
) else {
+3 -1
View File
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies(
relayLookup: { geohash, count in
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
NostrRelayManager.shared.sendEvent(event, to: relays)
},
deriveIdentity: { geohash in
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
try idBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
)
+7 -5
View File
@@ -16,9 +16,11 @@ final class NostrTransport: Transport {
private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge
init(keychain: KeychainManagerProtocol) {
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
self.keychain = keychain
self.idBridge = idBridge
}
// MARK: - Transport Protocol Conformance
@@ -65,7 +67,7 @@ final class NostrTransport: Transport {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only)
let recipientHex: String
@@ -102,7 +104,7 @@ final class NostrTransport: Transport {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
@@ -129,7 +131,7 @@ final class NostrTransport: Transport {
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
@@ -212,7 +214,7 @@ extension NostrTransport {
let item = readQueue.removeFirst()
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
+8 -2
View File
@@ -27,6 +27,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
private var peerIndex: [PeerID: BitchatPeer] = [:]
private var fingerprintCache: [PeerID: String] = [:]
private let meshService: Transport
private let idBridge: NostrIdentityBridge
private let identityManager: SecureIdentityStateManagerProtocol
weak var messageRouter: MessageRouter?
private let favoritesService = FavoritesPersistenceService.shared
@@ -34,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Initialization
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
init(
meshService: Transport,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.meshService = meshService
self.idBridge = idBridge
self.identityManager = identityManager
// Subscribe to changes from both services
@@ -285,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var peerNostrKey = peer.nostrPublicKey
if peerNostrKey == nil {
// Try to get from NostrIdentityBridge association
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
}
// Add favorite
+41 -38
View File
@@ -346,6 +346,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Services and Storage
let meshService: Transport
let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol
private var nostrRelayManager: NostrRelayManager?
@@ -493,11 +494,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol
) {
self.keychain = keychain
self.idBridge = idBridge
self.identityManager = identityManager
self.meshService = BLEService(keychain: keychain, identityManager: identityManager)
self.meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
// Load persisted read receipts
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
@@ -511,8 +514,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Initialize services
self.commandProcessor = CommandProcessor(identityManager: identityManager)
self.privateChatManager = PrivateChatManager(meshService: meshService)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, identityManager: identityManager)
let nostrTransport = NostrTransport(keychain: keychain)
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
// Route receipts from PrivateChatManager through MessageRouter
self.privateChatManager.messageRouter = self.messageRouter
@@ -715,7 +718,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
guard let self = self else { return }
Task { @MainActor in
guard case .location(let ch) = self.activeChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let key = id.publicKeyHex.lowercased()
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
@@ -941,7 +944,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil
}
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
@@ -961,7 +964,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
processedNostrEvents.insert(event.id)
if let gh = currentGeohash,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -993,7 +996,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let key = event.pubkey.lowercased()
// Do not mark our own key from historical events; rely on manager.teleported for self
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == key
}
return false
@@ -1125,7 +1128,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !sentGeoDeliveryAcks.contains(messageId) else { return }
let nt = NostrTransport(keychain: keychain)
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
sentGeoDeliveryAcks.insert(messageId)
@@ -1133,7 +1136,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !sentReadReceipts.contains(messageId) else { return }
let nt = NostrTransport(keychain: keychain)
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
sentReadReceipts.insert(messageId)
@@ -1255,7 +1258,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
let finalNickname = nickname ?? "Unknown"
let nostrKey = currentStatus?.peerNostrPublicKey ?? NostrIdentityBridge.getNostrPublicKey(for: noisePublicKey)
let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noisePublicKey,
@@ -1422,7 +1425,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var displaySender = nickname
var localSenderPeerID = meshService.myPeerID
if case .location(let ch) = activeChannel,
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
displaySender = nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex)
@@ -1483,7 +1486,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func sendGeohash(ch: GeohashChannel, content: String) {
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
@@ -1579,7 +1582,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
currentGeohash = ch.geohash
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
recordGeoParticipant(pubkeyHex: id.publicKeyHex)
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
@@ -1623,7 +1626,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
let isSelf: Bool = {
if let gh = currentGeohash, let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
if let gh = currentGeohash, let my = try? idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
@@ -1699,7 +1702,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func subscribeToGeoChat(_ ch: GeohashChannel) {
guard let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
guard let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) else { return }
let dmSub = "geo-dm-\(ch.geohash)"
geoDmSubscriptionID = dmSub
@@ -1901,12 +1904,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
let gh = currentGeohash,
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
if mapped == myIdentity.publicKeyHex.lowercased() { return true }
}
if let gh = currentGeohash,
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
let myLower = myIdentity.publicKeyHex.lowercased()
let shortLen = TransportConfig.nostrShortKeyDisplayLength
let shortKey = "nostr:" + myLower.prefix(shortLen)
@@ -2084,7 +2087,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
// Skip self identity for this geohash
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
if let my = try? idBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
// Only trigger when there were zero participants in this geohash recently
guard existingCount == 0 else { return }
@@ -2158,7 +2161,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4))
// If this is our per-geohash identity, use our nickname
if let gh = currentGeohash, let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
if let gh = currentGeohash, let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
return nickname + "#" + suffix
}
@@ -2344,7 +2347,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Send via Nostr using per-geohash identity
do {
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let id = try idBridge.deriveIdentity(forGeohash: ch.geohash)
// Prevent messaging ourselves
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@@ -2355,7 +2358,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
return
}
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
let nostrTransport = NostrTransport(keychain: keychain)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
@@ -2919,7 +2922,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
case .location(let ch):
Task { @MainActor in
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: screenshotMessage,
geohash: ch.geohash,
@@ -3020,12 +3023,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if peerID.hasPrefix("nostr_"),
let recipientHex = nostrKeyMapping[peerID],
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let messages = privateChats[peerID] ?? []
for message in messages where message.senderPeerID == peerID && !message.isRelay {
if !sentReadReceipts.contains(message.id) {
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))", category: .session)
let nostrTransport = NostrTransport(keychain: keychain)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
sentReadReceipts.insert(message.id)
@@ -3218,7 +3221,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
nostrRelayManager = nil
// Clear Nostr identity associations
NostrIdentityBridge.clearAllAssociations()
idBridge.clearAllAssociations()
// Disconnect from all peers and clear persistent identity
// This will force creation of a new identity (new fingerprint) on next launch
@@ -3263,7 +3266,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
tokens.insert("\(nick)#\(suffix)")
}
// Optionally exclude self nick#abcd from suggestions
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4))
tokens.remove(myToken)
}
@@ -3314,7 +3317,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let spid = message.senderPeerID?.id {
// In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
}
}
@@ -3492,7 +3495,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let (mBase, mSuffix) = matchText.splitSuffix()
// Determine if this mention targets me (resolves with optional suffix per active channel)
let mySuffix: String? = {
if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if case .location(let ch) = activeChannel, let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return String(id.publicKeyHex.suffix(4))
}
return String(meshService.myPeerID.id.prefix(4))
@@ -4068,7 +4071,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
@@ -4090,7 +4093,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// Build seeds map from currently visible geohash people (excluding self)
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
@@ -5011,7 +5014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if case .location(let ch) = activeChannel {
Task { @MainActor in
do {
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: ch.geohash,
@@ -5041,7 +5044,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func setupNostrMessageHandling() {
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else {
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
@@ -5079,7 +5082,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
private func processNostrMessage(_ giftWrap: NostrEvent) {
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
@@ -5298,9 +5301,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let key {
SecureLogger.debug("Sending DELIVERED ack for \(message.id.prefix(8))… via router", category: .session)
messageRouter.sendDeliveryAck(message.id, to: PeerID(hexData: key))
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
} else if let id = try? idBridge.getCurrentNostrIdentity() {
// Fallback: no Noise mapping yet send directly to sender's Nostr pubkey
let nt = NostrTransport(keychain: keychain)
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
SecureLogger.debug("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", category: .session)
@@ -5320,8 +5323,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
sentReadReceipts.insert(message.id)
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain)
} else if let id = try? idBridge.getCurrentNostrIdentity() {
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
nt.senderPeerID = meshService.myPeerID
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
sentReadReceipts.insert(message.id)
@@ -6156,7 +6159,7 @@ private func checkForMentions(_ message: BitchatMessage) {
#if os(iOS)
switch activeChannel {
case .location(let ch):
if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
let d = String(id.publicKeyHex.suffix(4))
tokens.append(nickname + "#" + d)
}
@@ -91,6 +91,7 @@ struct TextMessageView: View {
.environmentObject(
ChatViewModel(
keychain: keychain,
idBridge: NostrIdentityBridge(),
identityManager: SecureIdentityStateManager(keychain)
)
)
+1 -1
View File
@@ -28,7 +28,7 @@ struct GeohashPeopleList: View {
} else {
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
let id = try? viewModel.idBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
+1 -1
View File
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
let npub = try? viewModel.idBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
}
@@ -15,15 +15,21 @@ struct FragmentationTests {
private let mockKeychain: MockKeychain
private let mockIdentityManager: MockIdentityManager
private let idBridge: NostrIdentityBridge
init() {
mockKeychain = MockKeychain()
mockIdentityManager = MockIdentityManager(mockKeychain)
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
}
@Test("Reassembly from fragments delivers a public message")
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -55,7 +61,11 @@ struct FragmentationTests {
@Test("Duplicate fragment does not break reassembly")
func duplicateFragmentDoesNotBreakReassembly() async throws {
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
@@ -85,7 +95,11 @@ struct FragmentationTests {
@Test("Invalid fragment header is ignored")
func invalidFragmentHeaderIsIgnored() async throws {
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
let ble = BLEService(
keychain: mockKeychain,
idBridge: idBridge,
identityManager: mockIdentityManager
)
let capture = CaptureDelegate()
ble.delegate = capture
+3 -2
View File
@@ -37,9 +37,10 @@ final class LocationChannelsTests: XCTestCase {
func testPerGeohashIdentityDeterministic() throws {
// Derive twice for same geohash; should be identical
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let gh = "u4pruy"
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
}
}
+18
View File
@@ -44,3 +44,21 @@ final class MockKeychain: KeychainManagerProtocol {
storage["identity_noiseStaticKey"] != nil
}
}
final class MockKeychainHelper: KeychainHelperProtocol {
private typealias Service = String
private typealias Key = String
private var storage: [Service: [Key: Data]] = [:]
func save(key: String, data: Data, service: String, accessible: CFString?) {
storage[service]?[key] = data
}
func load(key: String, service: String) -> Data? {
storage[service]?[key]
}
func delete(key: String, service: String) {
storage[service]?.removeValue(forKey: key)
}
}