mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
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:
@@ -26,11 +26,15 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
|
let idBridge = self.idBridge
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
wrappedValue: ChatViewModel(
|
wrappedValue: ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -50,7 +54,7 @@ struct BitchatApp: App {
|
|||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
DispatchQueue.global(qos: .utility).async {
|
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)
|
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
import P256K
|
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
|
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||||
struct NostrIdentity: Codable {
|
struct NostrIdentity: Codable {
|
||||||
@@ -103,266 +58,3 @@ struct NostrIdentity: Codable {
|
|||||||
return publicKey.hexEncodedString()
|
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
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ final class BLEService: NSObject {
|
|||||||
private var noiseService: NoiseEncryptionService
|
private var noiseService: NoiseEncryptionService
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private var myPeerIDData: Data = Data()
|
private var myPeerIDData: Data = Data()
|
||||||
|
|
||||||
// MARK: - Advertising Privacy
|
// MARK: - Advertising Privacy
|
||||||
@@ -198,8 +199,13 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
keychain: KeychainManagerProtocol,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
@@ -613,7 +619,7 @@ final class BLEService: NSObject {
|
|||||||
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
var content = isFavorite ? "[FAVORITED]" : "[UNFAVORITED]"
|
||||||
|
|
||||||
// Add our Nostr public key if available
|
// Add our Nostr public key if available
|
||||||
if let myNostrIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
if let myNostrIdentity = try? idBridge.getCurrentNostrIdentity() {
|
||||||
content += ":" + myNostrIdentity.npub
|
content += ":" + myNostrIdentity.npub
|
||||||
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
|
SecureLogger.debug("📝 Sending favorite notification with Nostr npub: \(myNostrIdentity.npub)", category: .session)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ final class CommandProcessor {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
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
|
let people = vm.visibleGeohashPeople().filter { person in
|
||||||
if let me = myHex { return person.id.lowercased() != me }
|
if let me = myHex { return person.id.lowercased() != me }
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
private static let storageKey = "chat.bitchat.favorites"
|
private static let storageKey = "chat.bitchat.favorites"
|
||||||
private static let keychainService = "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 favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||||
@@ -35,7 +36,8 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
private init() {
|
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||||
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
// Update mutual favorites when favorites change
|
// Update mutual favorites when favorites change
|
||||||
@@ -196,7 +198,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
|
||||||
// Delete from keychain directly
|
// Delete from keychain directly
|
||||||
KeychainHelper.delete(
|
keychain.delete(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
)
|
)
|
||||||
@@ -216,10 +218,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
let data = try encoder.encode(relationships)
|
let data = try encoder.encode(relationships)
|
||||||
|
|
||||||
// Store in keychain for security
|
// Store in keychain for security
|
||||||
KeychainHelper.save(
|
keychain.save(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
data: data,
|
data: data,
|
||||||
service: Self.keychainService
|
service: Self.keychainService,
|
||||||
|
accessible: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// Successfully saved favorites
|
// Successfully saved favorites
|
||||||
@@ -231,7 +234,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
private func loadFavorites() {
|
private func loadFavorites() {
|
||||||
// Loading favorites from keychain
|
// Loading favorites from keychain
|
||||||
|
|
||||||
guard let data = KeychainHelper.load(
|
guard let data = keychain.load(
|
||||||
key: Self.storageKey,
|
key: Self.storageKey,
|
||||||
service: Self.keychainService
|
service: Self.keychainService
|
||||||
) else {
|
) else {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ struct LocationNotesDependencies {
|
|||||||
var sendEvent: SendEvent
|
var sendEvent: SendEvent
|
||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
@@ -35,7 +37,7 @@ struct LocationNotesDependencies {
|
|||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
},
|
},
|
||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||||
},
|
},
|
||||||
now: { Date() }
|
now: { Date() }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ final class NostrTransport: Transport {
|
|||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
@@ -65,7 +67,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
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)
|
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)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
@@ -102,7 +104,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
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)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
@@ -129,7 +131,7 @@ final class NostrTransport: Transport {
|
|||||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
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)
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -212,7 +214,7 @@ extension NostrTransport {
|
|||||||
let item = readQueue.removeFirst()
|
let item = readQueue.removeFirst()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
|
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)
|
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
// Convert recipient npub -> hex
|
// Convert recipient npub -> hex
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [PeerID: String] = [:]
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
private let favoritesService = FavoritesPersistenceService.shared
|
private let favoritesService = FavoritesPersistenceService.shared
|
||||||
@@ -34,8 +35,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(meshService: Transport, identityManager: SecureIdentityStateManagerProtocol) {
|
init(
|
||||||
|
meshService: Transport,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
|
|
||||||
// Subscribe to changes from both services
|
// Subscribe to changes from both services
|
||||||
@@ -285,7 +291,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
var peerNostrKey = peer.nostrPublicKey
|
var peerNostrKey = peer.nostrPublicKey
|
||||||
if peerNostrKey == nil {
|
if peerNostrKey == nil {
|
||||||
// Try to get from NostrIdentityBridge association
|
// Try to get from NostrIdentityBridge association
|
||||||
peerNostrKey = NostrIdentityBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
peerNostrKey = idBridge.getNostrPublicKey(for: peer.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add favorite
|
// Add favorite
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// MARK: - Services and Storage
|
// MARK: - Services and Storage
|
||||||
|
|
||||||
let meshService: Transport
|
let meshService: Transport
|
||||||
|
let idBridge: NostrIdentityBridge
|
||||||
let identityManager: SecureIdentityStateManagerProtocol
|
let identityManager: SecureIdentityStateManagerProtocol
|
||||||
|
|
||||||
private var nostrRelayManager: NostrRelayManager?
|
private var nostrRelayManager: NostrRelayManager?
|
||||||
@@ -493,11 +494,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@MainActor
|
@MainActor
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol
|
identityManager: SecureIdentityStateManagerProtocol
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
self.meshService = BLEService(keychain: keychain, identityManager: identityManager)
|
self.meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||||
|
|
||||||
// Load persisted read receipts
|
// Load persisted read receipts
|
||||||
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
|
if let data = UserDefaults.standard.data(forKey: "sentReadReceipts"),
|
||||||
@@ -511,8 +514,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Initialize services
|
// Initialize services
|
||||||
self.commandProcessor = CommandProcessor(identityManager: identityManager)
|
self.commandProcessor = CommandProcessor(identityManager: identityManager)
|
||||||
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
self.privateChatManager = PrivateChatManager(meshService: meshService)
|
||||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, identityManager: identityManager)
|
self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager)
|
||||||
let nostrTransport = NostrTransport(keychain: keychain)
|
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
// Route receipts from PrivateChatManager through MessageRouter
|
// Route receipts from PrivateChatManager through MessageRouter
|
||||||
self.privateChatManager.messageRouter = self.messageRouter
|
self.privateChatManager.messageRouter = self.messageRouter
|
||||||
@@ -715,7 +718,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard case .location(let ch) = self.activeChannel,
|
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 key = id.publicKeyHex.lowercased()
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
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
|
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)"
|
let dmSub = "geo-dm-\(ch.geohash)"
|
||||||
geoDmSubscriptionID = dmSub
|
geoDmSubscriptionID = dmSub
|
||||||
let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds))
|
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)
|
processedNostrEvents.insert(event.id)
|
||||||
|
|
||||||
if let gh = currentGeohash,
|
if let gh = currentGeohash,
|
||||||
let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh),
|
let myGeoIdentity = try? idBridge.deriveIdentity(forGeohash: gh),
|
||||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
// Skip very recent self-echo from relay, but allow older events (e.g., after app restart)
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
@@ -993,7 +996,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let key = event.pubkey.lowercased()
|
let key = event.pubkey.lowercased()
|
||||||
// Do not mark our own key from historical events; rely on manager.teleported for self
|
// Do not mark our own key from historical events; rely on manager.teleported for self
|
||||||
let isSelf: Bool = {
|
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 my.publicKeyHex.lowercased() == key
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -1125,7 +1128,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
private func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
private func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||||
guard !sentGeoDeliveryAcks.contains(messageId) else { return }
|
guard !sentGeoDeliveryAcks.contains(messageId) else { return }
|
||||||
let nt = NostrTransport(keychain: keychain)
|
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
nt.senderPeerID = meshService.myPeerID
|
nt.senderPeerID = meshService.myPeerID
|
||||||
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
|
nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
sentGeoDeliveryAcks.insert(messageId)
|
sentGeoDeliveryAcks.insert(messageId)
|
||||||
@@ -1133,7 +1136,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
private func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
private func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||||
guard !sentReadReceipts.contains(messageId) else { return }
|
guard !sentReadReceipts.contains(messageId) else { return }
|
||||||
let nt = NostrTransport(keychain: keychain)
|
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
nt.senderPeerID = meshService.myPeerID
|
nt.senderPeerID = meshService.myPeerID
|
||||||
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
|
nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
sentReadReceipts.insert(messageId)
|
sentReadReceipts.insert(messageId)
|
||||||
@@ -1255,7 +1258,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let finalNickname = nickname ?? "Unknown"
|
let finalNickname = nickname ?? "Unknown"
|
||||||
let nostrKey = currentStatus?.peerNostrPublicKey ?? NostrIdentityBridge.getNostrPublicKey(for: noisePublicKey)
|
let nostrKey = currentStatus?.peerNostrPublicKey ?? idBridge.getNostrPublicKey(for: noisePublicKey)
|
||||||
|
|
||||||
FavoritesPersistenceService.shared.addFavorite(
|
FavoritesPersistenceService.shared.addFavorite(
|
||||||
peerNoisePublicKey: noisePublicKey,
|
peerNoisePublicKey: noisePublicKey,
|
||||||
@@ -1422,7 +1425,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
var displaySender = nickname
|
var displaySender = nickname
|
||||||
var localSenderPeerID = meshService.myPeerID
|
var localSenderPeerID = meshService.myPeerID
|
||||||
if case .location(let ch) = activeChannel,
|
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))
|
let suffix = String(myGeoIdentity.publicKeyHex.suffix(4))
|
||||||
displaySender = nickname + "#" + suffix
|
displaySender = nickname + "#" + suffix
|
||||||
localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex)
|
localSenderPeerID = PeerID(nostr: myGeoIdentity.publicKeyHex)
|
||||||
@@ -1483,7 +1486,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func sendGeohash(ch: GeohashChannel, content: String) {
|
private func sendGeohash(ch: GeohashChannel, content: String) {
|
||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
|
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: content,
|
content: content,
|
||||||
@@ -1579,7 +1582,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
currentGeohash = ch.geohash
|
currentGeohash = ch.geohash
|
||||||
|
|
||||||
// Ensure self appears immediately in the people list; mark teleported state only when truly teleported
|
// 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)
|
recordGeoParticipant(pubkeyHex: id.publicKeyHex)
|
||||||
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty
|
||||||
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash }
|
||||||
@@ -1623,7 +1626,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isSelf: Bool = {
|
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 my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -1699,7 +1702,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func subscribeToGeoChat(_ ch: GeohashChannel) {
|
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)"
|
let dmSub = "geo-dm-\(ch.geohash)"
|
||||||
geoDmSubscriptionID = dmSub
|
geoDmSubscriptionID = dmSub
|
||||||
@@ -1901,12 +1904,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
if let mapped = nostrKeyMapping[peerID]?.lowercased(),
|
||||||
let gh = currentGeohash,
|
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 mapped == myIdentity.publicKeyHex.lowercased() { return true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let gh = currentGeohash,
|
if let gh = currentGeohash,
|
||||||
let myIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) {
|
let myIdentity = try? idBridge.deriveIdentity(forGeohash: gh) {
|
||||||
let myLower = myIdentity.publicKeyHex.lowercased()
|
let myLower = myIdentity.publicKeyHex.lowercased()
|
||||||
let shortLen = TransportConfig.nostrShortKeyDisplayLength
|
let shortLen = TransportConfig.nostrShortKeyDisplayLength
|
||||||
let shortKey = "nostr:" + myLower.prefix(shortLen)
|
let shortKey = "nostr:" + myLower.prefix(shortLen)
|
||||||
@@ -2084,7 +2087,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||||
|
|
||||||
// Skip self identity for this geohash
|
// 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
|
// Only trigger when there were zero participants in this geohash recently
|
||||||
guard existingCount == 0 else { return }
|
guard existingCount == 0 else { return }
|
||||||
@@ -2158,7 +2161,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||||
let suffix = String(pubkeyHex.suffix(4))
|
let suffix = String(pubkeyHex.suffix(4))
|
||||||
// If this is our per-geohash identity, use our nickname
|
// 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() {
|
if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
|
||||||
return nickname + "#" + suffix
|
return nickname + "#" + suffix
|
||||||
}
|
}
|
||||||
@@ -2344,7 +2347,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Send via Nostr using per-geohash identity
|
// Send via Nostr using per-geohash identity
|
||||||
do {
|
do {
|
||||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
let id = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
// Prevent messaging ourselves
|
// Prevent messaging ourselves
|
||||||
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
|
if recipientHex.lowercased() == id.publicKeyHex.lowercased() {
|
||||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||||
@@ -2355,7 +2358,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
SecureLogger.debug("GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session)
|
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.senderPeerID = meshService.myPeerID
|
||||||
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
|
nostrTransport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: id, messageID: messageID)
|
||||||
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
if let msgIdx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||||
@@ -2919,7 +2922,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: screenshotMessage,
|
content: screenshotMessage,
|
||||||
geohash: ch.geohash,
|
geohash: ch.geohash,
|
||||||
@@ -3020,12 +3023,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if peerID.hasPrefix("nostr_"),
|
if peerID.hasPrefix("nostr_"),
|
||||||
let recipientHex = nostrKeyMapping[peerID],
|
let recipientHex = nostrKeyMapping[peerID],
|
||||||
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
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] ?? []
|
let messages = privateChats[peerID] ?? []
|
||||||
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
for message in messages where message.senderPeerID == peerID && !message.isRelay {
|
||||||
if !sentReadReceipts.contains(message.id) {
|
if !sentReadReceipts.contains(message.id) {
|
||||||
SecureLogger.debug("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", category: .session)
|
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.senderPeerID = meshService.myPeerID
|
||||||
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
|
nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id)
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
@@ -3218,7 +3221,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
nostrRelayManager = nil
|
nostrRelayManager = nil
|
||||||
|
|
||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
NostrIdentityBridge.clearAllAssociations()
|
idBridge.clearAllAssociations()
|
||||||
|
|
||||||
// Disconnect from all peers and clear persistent identity
|
// Disconnect from all peers and clear persistent identity
|
||||||
// This will force creation of a new identity (new fingerprint) on next launch
|
// 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)")
|
tokens.insert("\(nick)#\(suffix)")
|
||||||
}
|
}
|
||||||
// Optionally exclude self nick#abcd from suggestions
|
// 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))
|
let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4))
|
||||||
tokens.remove(myToken)
|
tokens.remove(myToken)
|
||||||
}
|
}
|
||||||
@@ -3314,7 +3317,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let spid = message.senderPeerID?.id {
|
if let spid = message.senderPeerID?.id {
|
||||||
// In geohash channels, compare against our per-geohash nostr short ID
|
// In geohash channels, compare against our per-geohash nostr short ID
|
||||||
if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") {
|
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))"
|
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3492,7 +3495,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let (mBase, mSuffix) = matchText.splitSuffix()
|
let (mBase, mSuffix) = matchText.splitSuffix()
|
||||||
// Determine if this mention targets me (resolves with optional suffix per active channel)
|
// Determine if this mention targets me (resolves with optional suffix per active channel)
|
||||||
let mySuffix: String? = {
|
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(id.publicKeyHex.suffix(4))
|
||||||
}
|
}
|
||||||
return String(meshService.myPeerID.id.prefix(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 entry = (isDark ? nostrPaletteDark[pubkeyHexLowercased] : nostrPaletteLight[pubkeyHexLowercased])
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
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 id.publicKeyHex.lowercased()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -4090,7 +4093,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Build seeds map from currently visible geohash people (excluding self)
|
// Build seeds map from currently visible geohash people (excluding self)
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
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 id.publicKeyHex.lowercased()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -5011,7 +5014,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if case .location(let ch) = activeChannel {
|
if case .location(let ch) = activeChannel {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash)
|
let identity = try idBridge.deriveIdentity(forGeohash: ch.geohash)
|
||||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content: content,
|
content: content,
|
||||||
geohash: ch.geohash,
|
geohash: ch.geohash,
|
||||||
@@ -5041,7 +5044,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func setupNostrMessageHandling() {
|
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)
|
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5079,7 +5082,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func processNostrMessage(_ giftWrap: NostrEvent) {
|
private func processNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
guard let currentIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||||
@@ -5298,9 +5301,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
if let key {
|
if let key {
|
||||||
SecureLogger.debug("Sending DELIVERED ack for \(message.id.prefix(8))… via router", category: .session)
|
SecureLogger.debug("Sending DELIVERED ack for \(message.id.prefix(8))… via router", category: .session)
|
||||||
messageRouter.sendDeliveryAck(message.id, to: PeerID(hexData: key))
|
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
|
// 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.senderPeerID = meshService.myPeerID
|
||||||
nt.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: id)
|
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)
|
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)
|
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||||
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
} else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() {
|
} else if let id = try? idBridge.getCurrentNostrIdentity() {
|
||||||
let nt = NostrTransport(keychain: keychain)
|
let nt = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
nt.senderPeerID = meshService.myPeerID
|
nt.senderPeerID = meshService.myPeerID
|
||||||
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
nt.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: id)
|
||||||
sentReadReceipts.insert(message.id)
|
sentReadReceipts.insert(message.id)
|
||||||
@@ -6156,7 +6159,7 @@ private func checkForMentions(_ message: BitchatMessage) {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
switch activeChannel {
|
switch activeChannel {
|
||||||
case .location(let ch):
|
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))
|
let d = String(id.publicKeyHex.suffix(4))
|
||||||
tokens.append(nickname + "#" + d)
|
tokens.append(nickname + "#" + d)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ struct TextMessageView: View {
|
|||||||
.environmentObject(
|
.environmentObject(
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
|
idBridge: NostrIdentityBridge(),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let myHex: String? = {
|
let myHex: String? = {
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
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 id.publicKeyHex.lowercased()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
|
|||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
private func myQRString() -> String {
|
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) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,21 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
private let mockKeychain: MockKeychain
|
private let mockKeychain: MockKeychain
|
||||||
private let mockIdentityManager: MockIdentityManager
|
private let mockIdentityManager: MockIdentityManager
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||||
|
idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Reassembly from fragments delivers a public message")
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -55,7 +61,11 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Duplicate fragment does not break reassembly")
|
@Test("Duplicate fragment does not break reassembly")
|
||||||
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
@@ -85,7 +95,11 @@ struct FragmentationTests {
|
|||||||
|
|
||||||
@Test("Invalid fragment header is ignored")
|
@Test("Invalid fragment header is ignored")
|
||||||
func invalidFragmentHeaderIsIgnored() async throws {
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
let ble = BLEService(
|
||||||
|
keychain: mockKeychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: mockIdentityManager
|
||||||
|
)
|
||||||
let capture = CaptureDelegate()
|
let capture = CaptureDelegate()
|
||||||
ble.delegate = capture
|
ble.delegate = capture
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ final class LocationChannelsTests: XCTestCase {
|
|||||||
|
|
||||||
func testPerGeohashIdentityDeterministic() throws {
|
func testPerGeohashIdentityDeterministic() throws {
|
||||||
// Derive twice for same geohash; should be identical
|
// Derive twice for same geohash; should be identical
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
let gh = "u4pruy"
|
let gh = "u4pruy"
|
||||||
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
let id1 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
let id2 = try idBridge.deriveIdentity(forGeohash: gh)
|
||||||
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,21 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
storage["identity_noiseStaticKey"] != nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user