diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index e470fd47..9e63eb6b 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -140,6 +140,8 @@ F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; + A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; + A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -258,6 +260,7 @@ FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = ""; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; + A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -522,6 +525,7 @@ E78C7F4B6769C0A72F5DE544 /* Nostr */ = { isa = PBXGroup; children = ( + A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */, 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */, 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */, @@ -720,6 +724,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, @@ -774,6 +779,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 81ba5c62..5284dd70 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -1,6 +1,7 @@ import Foundation import CryptoKit import P256K +import Security // Note: This file depends on Data extension from BinaryEncodingUtils.swift // Make sure BinaryEncodingUtils.swift is included in the target @@ -12,8 +13,9 @@ struct NostrProtocol { enum EventKind: Int { case metadata = 0 case textNote = 1 + case dm = 14 // NIP-17 DM rumor kind case seal = 13 // NIP-17 sealed event - case giftWrap = 1059 // NIP-17 gift wrap + case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 } @@ -30,7 +32,7 @@ struct NostrProtocol { let rumor = NostrEvent( pubkey: senderIdentity.publicKeyHex, createdAt: Date(), - kind: .textNote, + kind: .dm, // NIP-17: DM rumor kind 14 tags: [], content: content ) @@ -227,7 +229,7 @@ struct NostrProtocol { return try NostrEvent(from: rumorDict) } - // MARK: - Encryption (NIP-44 style) + // MARK: - Encryption (NIP-44 v2) private static func encrypt( plaintext: String, @@ -239,33 +241,31 @@ struct NostrProtocol { throw NostrError.invalidPublicKey } - // Encrypting message + // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) // Derive shared secret let sharedSecret = try deriveSharedSecret( privateKey: senderKey, publicKey: recipientPubkeyData ) + // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) + let key = try deriveNIP44V2Key(from: sharedSecret) - // Derived shared secret + // 24-byte random nonce for XChaCha20-Poly1305 + var nonce24 = Data(count: 24) + _ = nonce24.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) + } - // Generate nonce - let nonce = AES.GCM.Nonce() + let pt = Data(plaintext.utf8) + let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) - // Encrypt - let sealed = try AES.GCM.seal( - plaintext.data(using: .utf8)!, - using: SymmetricKey(data: sharedSecret), - nonce: nonce - ) - - // Combine nonce + ciphertext + tag - var result = Data() - result.append(nonce.withUnsafeBytes { Data($0) }) - result.append(sealed.ciphertext) - result.append(sealed.tag) - - return result.base64EncodedString() + // v2: base64url(nonce24 || ciphertext || tag) + var combined = Data() + combined.append(nonce24) + combined.append(sealed.ciphertext) + combined.append(sealed.tag) + return "v2:" + base64URLEncode(combined) } private static func decrypt( @@ -273,86 +273,45 @@ struct NostrProtocol { senderPubkey: String, recipientKey: P256K.Schnorr.PrivateKey ) throws -> String { - - // Decrypting message - - guard let data = Data(base64Encoded: ciphertext), + // Expect NIP-44 v2 format + guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } + let encoded = String(ciphertext.dropFirst(3)) + guard let data = base64URLDecode(encoded), + data.count > (24 + 16), let senderPubkeyData = Data(hexString: senderPubkey) else { - SecureLogger.log("❌ Invalid ciphertext or sender pubkey format", - category: SecureLogger.session, level: .error) throw NostrError.invalidCiphertext } - - // Ciphertext data parsed - - // Extract components - let nonceData = data.prefix(12) - let ciphertextData = data.dropFirst(12).dropLast(16) - let tagData = data.suffix(16) - - // Components parsed - - // Derive shared secret - try with default Y coordinate first - var sharedSecret: Data - var decrypted: Data? = nil - - do { - sharedSecret = try deriveSharedSecret( - privateKey: recipientKey, - publicKey: senderPubkeyData + + let nonce24 = data.prefix(24) + let rest = data.dropFirst(24) + let tag = rest.suffix(16) + let ct = rest.dropLast(16) + + // Try decryption with even-Y then odd-Y when sender pubkey is x-only + func attemptDecrypt(using pubKeyData: Data) throws -> Data { + let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) + let key = try deriveNIP44V2Key(from: ss) + return try XChaCha20Poly1305Compat.open( + ciphertext: Data(ct), + tag: Data(tag), + key: key, + nonce24: Data(nonce24) ) - // Derived shared secret with first Y coordinate - - // Try to decrypt - let sealedBox = try AES.GCM.SealedBox( - nonce: AES.GCM.Nonce(data: nonceData), - ciphertext: ciphertextData, - tag: tagData - ) - - do { - decrypted = try AES.GCM.open( - sealedBox, - using: SymmetricKey(data: sharedSecret) - ) - // AES-GCM decryption successful - } catch { - // AES-GCM decryption failed, trying alternate - - // If the sender pubkey is x-only (32 bytes), try the other Y coordinate - if senderPubkeyData.count == 32 { - // Trying alternate Y coordinate - - // Force deriveSharedSecret to use odd Y by manipulating the data - var altPubkey = Data() - altPubkey.append(0x03) // Force odd Y - altPubkey.append(senderPubkeyData) - - sharedSecret = try deriveSharedSecretDirect( - privateKey: recipientKey, - publicKey: altPubkey - ) - - decrypted = try AES.GCM.open( - sealedBox, - using: SymmetricKey(data: sharedSecret) - ) - // AES-GCM decryption successful with alternate Y - } else { - throw error - } + } + + // If 32 bytes (x-only) try both parities, otherwise single try + if senderPubkeyData.count == 32 { + let even = Data([0x02]) + senderPubkeyData + if let pt = try? attemptDecrypt(using: even) { + return String(data: pt, encoding: .utf8) ?? "" } - } catch { - SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)", - category: SecureLogger.session, level: .error) - throw error + let odd = Data([0x03]) + senderPubkeyData + let pt = try attemptDecrypt(using: odd) + return String(data: pt, encoding: .utf8) ?? "" + } else { + let pt = try attemptDecrypt(using: senderPubkeyData) + return String(data: pt, encoding: .utf8) ?? "" } - - guard let finalDecrypted = decrypted else { - throw NostrError.encryptionFailed - } - - return String(data: finalDecrypted, encoding: .utf8) ?? "" } private static func deriveSharedSecret( @@ -412,17 +371,8 @@ struct NostrProtocol { let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } // ECDH shared secret derived - // Derive key using HKDF for NIP-44 v2 - let derivedKey = HKDF.deriveKey( - inputKeyMaterial: SymmetricKey(data: sharedSecretData), - salt: "nip44-v2".data(using: .utf8)!, - info: Data(), - outputByteCount: 32 - ) - - let result = derivedKey.withUnsafeBytes { Data($0) } - // Final derived key ready - return result + // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key + return sharedSecretData } // Direct version that doesn't try to add prefixes @@ -452,15 +402,8 @@ struct NostrProtocol { // Convert SharedSecret to Data let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } - // Derive key using HKDF for NIP-44 v2 - let derivedKey = HKDF.deriveKey( - inputKeyMaterial: SymmetricKey(data: sharedSecretData), - salt: "nip44-v2".data(using: .utf8)!, - info: Data(), - outputByteCount: 32 - ) - - return derivedKey.withUnsafeBytes { Data($0) } + // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key + return sharedSecretData } private static func randomizedTimestamp() -> Date { @@ -537,7 +480,10 @@ struct NostrEvent: Codable { // Sign with Schnorr var messageBytes = [UInt8](eventIdHash) - var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing + var auxRand = [UInt8](repeating: 0, count: 32) + _ = auxRand.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand) let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() @@ -581,3 +527,32 @@ enum NostrError: Error { case signingFailed case encryptionFailed } + +// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url) + +private extension NostrProtocol { + static func base64URLEncode(_ data: Data) -> String { + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func base64URLDecode(_ s: String) -> Data? { + var str = s + let pad = (4 - (str.count % 4)) % 4 + if pad > 0 { str += String(repeating: "=", count: pad) } + str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + return Data(base64Encoded: str) + } + + static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data { + let derivedKey = HKDF.deriveKey( + inputKeyMaterial: SymmetricKey(data: sharedSecretData), + salt: Data(), + info: "nip44-v2".data(using: .utf8)!, + outputByteCount: 32 + ) + return derivedKey.withUnsafeBytes { Data($0) } + } +} diff --git a/bitchat/Nostr/XChaCha20Poly1305Compat.swift b/bitchat/Nostr/XChaCha20Poly1305Compat.swift new file mode 100644 index 00000000..44f1ca97 --- /dev/null +++ b/bitchat/Nostr/XChaCha20Poly1305Compat.swift @@ -0,0 +1,116 @@ +import Foundation +import CryptoKit + +/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly. +/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce +/// as per XChaCha20 construction. +enum XChaCha20Poly1305Compat { + struct SealBox { + let ciphertext: Data + let tag: Data + } + + static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox { + precondition(key.count == 32, "XChaCha20 key must be 32 bytes") + precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes") + + let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16)) + let nonce12 = derive12ByteNonce(from24: nonce24) + let chachaKey = SymmetricKey(data: subkey) + let nonce = try ChaChaPoly.Nonce(data: nonce12) + let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data()) + return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag) + } + + static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data { + precondition(key.count == 32, "XChaCha20 key must be 32 bytes") + precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes") + + let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16)) + let nonce12 = derive12ByteNonce(from24: nonce24) + let chachaKey = SymmetricKey(data: subkey) + let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag) + return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data()) + } + + // MARK: - Internals + + private static func derive12ByteNonce(from24 nonce24: Data) -> Data { + // XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce + var out = Data(count: 12) + out.replaceSubrange(0..<4, with: [0, 0, 0, 0]) + out.replaceSubrange(4..<12, with: nonce24.suffix(8)) + return out + } + + private static func hchacha20(key: Data, nonce16: Data) -> Data { + // HChaCha20 based on the original ChaCha20 core with a 16-byte nonce. + precondition(key.count == 32) + precondition(nonce16.count == 16) + + // Constants "expand 32-byte k" + var state: [UInt32] = [ + 0x61707865, 0x3320646e, 0x79622d32, 0x6b206574, + // key (8 words) + key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12), + key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28), + // nonce (4 words) + nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12) + ] + + // 20 rounds (10 double rounds) + for _ in 0..<10 { + // Column rounds + quarterRound(&state, 0, 4, 8, 12) + quarterRound(&state, 1, 5, 9, 13) + quarterRound(&state, 2, 6, 10, 14) + quarterRound(&state, 3, 7, 11, 15) + // Diagonal rounds + quarterRound(&state, 0, 5, 10, 15) + quarterRound(&state, 1, 6, 11, 12) + quarterRound(&state, 2, 7, 8, 13) + quarterRound(&state, 3, 4, 9, 14) + } + + // Output subkey: state[0..3] and state[12..15] + var out = Data(count: 32) + out.storeLEWord(state[0], at: 0) + out.storeLEWord(state[1], at: 4) + out.storeLEWord(state[2], at: 8) + out.storeLEWord(state[3], at: 12) + out.storeLEWord(state[12], at: 16) + out.storeLEWord(state[13], at: 20) + out.storeLEWord(state[14], at: 24) + out.storeLEWord(state[15], at: 28) + return out + } + + private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) { + s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16) + s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20) + s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24) + s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25) + } +} + +private extension Data { + func loadLEWord(_ offset: Int) -> UInt32 { + let range = offset..<(offset+4) + let bytes = self[range] + return bytes.withUnsafeBytes { ptr -> UInt32 in + let b = ptr.bindMemory(to: UInt8.self) + return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24) + } + } + + mutating func storeLEWord(_ value: UInt32, at offset: Int) { + let bytes: [UInt8] = [ + UInt8(value & 0xff), + UInt8((value >> 8) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 24) & 0xff) + ] + replaceSubrange(offset..<(offset+4), with: bytes) + } +} + diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index d8741b90..da2d8dac 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -4480,10 +4480,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { privateChatManager.sanitizeChat(for: ephemeralPeerID) } - // Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key - if !wasReadBefore, let key = actualSenderNoiseKey { - SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) - messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) + // Send delivery ack via Nostr embedded + if !wasReadBefore { + if let key = actualSenderNoiseKey { + SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) + messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString()) + } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { + // Fallback: no Noise mapping yet — send directly to sender's Nostr pubkey + let nt = NostrTransport() + nt.senderPeerID = meshService.myPeerID + nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) + SecureLogger.log("Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: SecureLogger.session, level: .debug) + } } if wasReadBefore { @@ -4494,11 +4502,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id { unreadPrivateMessages.remove(ephemeralPeerID) } - if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey { - let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname) - SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) - messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) - sentReadReceipts.insert(messageId) + if !sentReadReceipts.contains(messageId) { + if let key = actualSenderNoiseKey { + let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname) + SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug) + messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString()) + sentReadReceipts.insert(messageId) + } else if let id = try? NostrIdentityBridge.getCurrentNostrIdentity() { + let nt = NostrTransport() + nt.senderPeerID = meshService.myPeerID + nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) + sentReadReceipts.insert(messageId) + SecureLogger.log("Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(messageId.prefix(8))…", category: SecureLogger.session, level: .debug) + } } } else { if shouldMarkAsUnread { diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index ec1ddd1f..3cc526f0 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -291,13 +291,17 @@ struct ContentView: View { // Build stable UI IDs with a context key to avoid ID collisions when switching channels #if os(iOS) let contextKey: String = { + if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() #else - let contextKey: String = "mesh" + let contextKey: String = { + if let peer = privatePeer { return "dm:\(peer)" } + return "mesh" + }() #endif let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } @@ -396,13 +400,17 @@ struct ContentView: View { let step = 200 #if os(iOS) let contextKey: String = { + if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() #else - let contextKey: String = "mesh" + let contextKey: String = { + if let peer = privatePeer { return "dm:\(peer)" } + return "mesh" + }() #endif let preserveID = "\(contextKey)|\(message.id)" if let peer = privatePeer { diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index dc67f958..7b1559ac 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -44,9 +44,24 @@ struct MeshPeerList: View { let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark) let baseColor = isMe ? Color.orange : assigned if isMe { - Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor) + Image(systemName: "person.fill") + .font(.system(size: 10)) + .foregroundColor(baseColor) + } else if peer.isConnected { + // Mesh-connected peer: radio icon + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 10)) + .foregroundColor(baseColor) + } else if peer.isMutualFavorite { + // Mutual favorite reachable via Nostr: globe icon (purple) + Image(systemName: "globe") + .font(.system(size: 10)) + .foregroundColor(.purple) } else { - Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor) + // Fallback icon for others (dimmed) + Image(systemName: "person") + .font(.system(size: 10)) + .foregroundColor(secondaryTextColor) } let displayName = isMe ? viewModel.nickname : peer.nickname diff --git a/bitchatTests/NostrProtocolTests.swift b/bitchatTests/NostrProtocolTests.swift index 5cb5bc04..ea49d96a 100644 --- a/bitchatTests/NostrProtocolTests.swift +++ b/bitchatTests/NostrProtocolTests.swift @@ -112,4 +112,109 @@ final class NostrProtocolTests: XCTestCase { print("Expected error when decrypting with wrong key: \(error)") } } -} \ No newline at end of file + + func testAckRoundTripNIP44V2_Delivered() throws { + // Identities + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + + // Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID) + let messageID = "TEST-MSG-DELIVERED-1" + let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { + XCTFail("Failed to embed delivered ack") + return + } + + // Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: embedded, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + // Ensure v2 format was used for ciphertext + XCTAssertTrue(giftWrap.content.hasPrefix("v2:")) + + // Decrypt as recipient + let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + + // Verify sender is correct + XCTAssertEqual(senderPubkey, sender.publicKeyHex) + + // Parse BitChat payload + XCTAssertTrue(content.hasPrefix("bitchat1:")) + let base64url = String(content.dropFirst("bitchat1:".count)) + guard let packetData = Self.base64URLDecode(base64url), + let packet = BitchatPacket.from(packetData) else { + return XCTFail("Failed to decode bitchat packet") + } + XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue) + guard let payload = NoisePayload.decode(packet.payload) else { + return XCTFail("Failed to decode NoisePayload") + } + switch payload.type { + case .delivered: + let mid = String(data: payload.data, encoding: .utf8) + XCTAssertEqual(mid, messageID) + default: + XCTFail("Unexpected payload type: \(payload.type)") + } + } + + func testAckRoundTripNIP44V2_ReadReceipt() throws { + // Identities + let sender = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + + let messageID = "TEST-MSG-READ-1" + let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { + XCTFail("Failed to embed read ack") + return + } + + let giftWrap = try NostrProtocol.createPrivateMessage( + content: embedded, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + XCTAssertTrue(giftWrap.content.hasPrefix("v2:")) + + let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: recipient + ) + XCTAssertEqual(senderPubkey, sender.publicKeyHex) + + XCTAssertTrue(content.hasPrefix("bitchat1:")) + let base64url = String(content.dropFirst("bitchat1:".count)) + guard let packetData = Self.base64URLDecode(base64url), + let packet = BitchatPacket.from(packetData) else { + return XCTFail("Failed to decode bitchat packet") + } + XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue) + guard let payload = NoisePayload.decode(packet.payload) else { + return XCTFail("Failed to decode NoisePayload") + } + switch payload.type { + case .readReceipt: + let mid = String(data: payload.data, encoding: .utf8) + XCTAssertEqual(mid, messageID) + default: + XCTFail("Unexpected payload type: \(payload.type)") + } + } + + // MARK: - Helpers + private static func base64URLDecode(_ s: String) -> Data? { + var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + let rem = str.count % 4 + if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) } + return Data(base64Encoded: str) + } +}