mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 19:25:23 +00:00
Compare commits
23
Commits
v1.4.1
..
peer-id-10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8daacb2dce | ||
|
|
2d8d45a4d7 | ||
|
|
7267fb6404 | ||
|
|
a6d013bee9 | ||
|
|
7b20bdf821 | ||
|
|
9be05f98e9 | ||
|
|
b97241978d | ||
|
|
a454a85d86 | ||
|
|
9ecd346197 | ||
|
|
94e19dc039 | ||
|
|
438357486f | ||
|
|
4a382716bb | ||
|
|
1563b06d73 | ||
|
|
abb7489e2f | ||
|
|
2bc875dee5 | ||
|
|
588cef72ce | ||
|
|
221819b591 | ||
|
|
4a21ab0531 | ||
|
|
50ae8da5f9 | ||
|
|
54bb812469 | ||
|
|
482fca81ef | ||
|
|
b2e7d2d26e | ||
|
|
6a2832d22b |
@@ -0,0 +1,20 @@
|
|||||||
|
# Prevent Github Languages stats skewing:
|
||||||
|
|
||||||
|
# Binaries and assets
|
||||||
|
**/*.xcframework/** linguist-vendored
|
||||||
|
**/*.xcassets/** linguist-vendored
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
**/*.pbxproj linguist-generated
|
||||||
|
**/*.storyboard linguist-generated
|
||||||
|
Package.resolved linguist-generated
|
||||||
|
|
||||||
|
# Downloaded CSVs
|
||||||
|
relays/online_relays_gps.csv linguist-vendored
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
**/*.md linguist-documentation
|
||||||
|
|
||||||
|
# Configs
|
||||||
|
Configs/*.xcconfig linguist-documentation
|
||||||
|
**/*.plist linguist-documentation
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.1
|
MARKETING_VERSION = 1.4.2
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
Generated
+10
-8
@@ -481,7 +481,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -498,7 +498,7 @@
|
|||||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
@@ -514,7 +514,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -574,7 +574,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -604,12 +604,13 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -683,7 +684,7 @@
|
|||||||
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
|
||||||
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
baseConfigurationReferenceRelativePath = Debug.xcconfig;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
|
||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
@@ -695,12 +696,13 @@
|
|||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -795,7 +797,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "image-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 1.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "image-1024 2.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -87,7 +87,7 @@ import Foundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: String // 8 random bytes
|
let peer: Peer // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Cryptographic Identities
|
// MARK: Cryptographic Identities
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
func getCryptoIdentitiesByPeerIDPrefix(_ peer: Peer) -> [CryptographicIdentity]
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func getBlockedNostrPubkeys() -> Set<String>
|
func getBlockedNostrPubkeys() -> Set<String>
|
||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
func registerEphemeralSession(peer: Peer, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
func updateHandshakeState(peer: Peer, state: HandshakeState)
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: String)
|
func removeEphemeralSession(peer: Peer)
|
||||||
|
|
||||||
// MARK: Verification
|
// MARK: Verification
|
||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [Peer: EphemeralIdentity] = [:]
|
||||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
private var cache: IdentityCache = IdentityCache()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
func getCryptoIdentitiesByPeerIDPrefix(_ peer: Peer) -> [CryptographicIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
guard peer.isShort, peer.id.allSatisfy({ $0.isHexDigit }) else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peer.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,19 +455,19 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peer: Peer, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peer] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peer: peer,
|
||||||
sessionStart: Date(),
|
sessionStart: Date(),
|
||||||
handshakeState: handshakeState
|
handshakeState: handshakeState
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
func updateHandshakeState(peer: Peer, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peer]?.handshakeState = state
|
||||||
|
|
||||||
// If handshake completed, update last interaction
|
// If handshake completed, update last interaction
|
||||||
if case .completed(let fingerprint) = state {
|
if case .completed(let fingerprint) = state {
|
||||||
@@ -493,9 +493,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: String) {
|
func removeEphemeralSession(peer: Peer) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
|||||||
let originalSender: String?
|
let originalSender: String?
|
||||||
let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeerID: String?
|
let senderPeer: Peer?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
@@ -39,10 +39,23 @@ final class BitchatMessage: Codable {
|
|||||||
// Codable implementation
|
// Codable implementation
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, sender, content, timestamp, isRelay, originalSender
|
case id, sender, content, timestamp, isRelay, originalSender
|
||||||
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
case isPrivate, recipientNickname, mentions, deliveryStatus
|
||||||
|
case senderPeer = "senderPeerID" // backwards compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
|
init(
|
||||||
|
id: String? = nil,
|
||||||
|
sender: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isRelay: Bool,
|
||||||
|
originalSender: String? = nil,
|
||||||
|
isPrivate: Bool = false,
|
||||||
|
recipientNickname: String? = nil,
|
||||||
|
senderPeer: Peer? = nil,
|
||||||
|
mentions: [String]? = nil,
|
||||||
|
deliveryStatus: DeliveryStatus? = nil
|
||||||
|
) {
|
||||||
self.id = id ?? UUID().uuidString
|
self.id = id ?? UUID().uuidString
|
||||||
self.sender = sender
|
self.sender = sender
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -51,7 +64,7 @@ final class BitchatMessage: Codable {
|
|||||||
self.originalSender = originalSender
|
self.originalSender = originalSender
|
||||||
self.isPrivate = isPrivate
|
self.isPrivate = isPrivate
|
||||||
self.recipientNickname = recipientNickname
|
self.recipientNickname = recipientNickname
|
||||||
self.senderPeerID = senderPeerID
|
self.senderPeer = senderPeer
|
||||||
self.mentions = mentions
|
self.mentions = mentions
|
||||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,7 @@ extension BitchatMessage: Equatable {
|
|||||||
lhs.originalSender == rhs.originalSender &&
|
lhs.originalSender == rhs.originalSender &&
|
||||||
lhs.isPrivate == rhs.isPrivate &&
|
lhs.isPrivate == rhs.isPrivate &&
|
||||||
lhs.recipientNickname == rhs.recipientNickname &&
|
lhs.recipientNickname == rhs.recipientNickname &&
|
||||||
lhs.senderPeerID == rhs.senderPeerID &&
|
lhs.senderPeer == rhs.senderPeer &&
|
||||||
lhs.mentions == rhs.mentions &&
|
lhs.mentions == rhs.mentions &&
|
||||||
lhs.deliveryStatus == rhs.deliveryStatus
|
lhs.deliveryStatus == rhs.deliveryStatus
|
||||||
}
|
}
|
||||||
@@ -101,7 +114,7 @@ extension BitchatMessage {
|
|||||||
if isPrivate { flags |= 0x02 }
|
if isPrivate { flags |= 0x02 }
|
||||||
if originalSender != nil { flags |= 0x04 }
|
if originalSender != nil { flags |= 0x04 }
|
||||||
if recipientNickname != nil { flags |= 0x08 }
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
if senderPeerID != nil { flags |= 0x10 }
|
if senderPeer != nil { flags |= 0x10 }
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
@@ -151,7 +164,7 @@ extension BitchatMessage {
|
|||||||
data.append(recipData.prefix(255))
|
data.append(recipData.prefix(255))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
|
if let peerData = senderPeer?.data {
|
||||||
data.append(UInt8(min(peerData.count, 255)))
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
data.append(peerData.prefix(255))
|
data.append(peerData.prefix(255))
|
||||||
}
|
}
|
||||||
@@ -264,11 +277,11 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var senderPeerID: String?
|
var senderPeer: Peer?
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
if offset + length <= dataCopy.count {
|
if offset + length <= dataCopy.count {
|
||||||
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
|
senderPeer = Peer(data: dataCopy[offset..<offset+length])
|
||||||
offset += length
|
offset += length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,7 +315,7 @@ extension BitchatMessage {
|
|||||||
originalSender: originalSender,
|
originalSender: originalSender,
|
||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: recipientNickname,
|
recipientNickname: recipientNickname,
|
||||||
senderPeerID: senderPeerID,
|
senderPeer: senderPeer,
|
||||||
mentions: mentions
|
mentions: mentions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//
|
||||||
|
// Peer.swift
|
||||||
|
// BitLogger
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import struct CryptoKit.SHA256
|
||||||
|
|
||||||
|
struct Peer: Equatable, Hashable {
|
||||||
|
let id: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Peer {
|
||||||
|
var data: Data? {
|
||||||
|
id.data(using: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNostr: Bool {
|
||||||
|
id.hasPrefix("nostr")
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNostrColon: Bool {
|
||||||
|
id.hasPrefix("nostr:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
extension Peer {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
|
var isValid: Bool {
|
||||||
|
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||||
|
if isShort || isNoiseKeyHex {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If length equals short or full but isn't valid hex, reject
|
||||||
|
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||||
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
|
return !id.isEmpty &&
|
||||||
|
id.count < Constants.maxIDLength &&
|
||||||
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short routing IDs (exact 16-hex)
|
||||||
|
var isShort: Bool {
|
||||||
|
id.count == Constants.hexIDLength && Data(hexString: id) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
var isNoiseKeyHex: Bool {
|
||||||
|
noiseKey != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key (exact 64-hex) as Data
|
||||||
|
var noiseKey: Data? {
|
||||||
|
guard id.count == Constants.maxIDLength else { return nil }
|
||||||
|
return Data(hexString: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ExpressibleByStringLiteral
|
||||||
|
|
||||||
|
extension Peer: ExpressibleByStringLiteral {
|
||||||
|
init(stringLiteral value: String) {
|
||||||
|
self.init(str: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ExpressibleByStringInterpolation
|
||||||
|
|
||||||
|
extension Peer: ExpressibleByStringInterpolation {
|
||||||
|
init(extendedGraphemeClusterLiteral value: String) {
|
||||||
|
self.init(str: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
extension Peer: Codable {
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
id = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
|
extension Peer {
|
||||||
|
init(str: String) {
|
||||||
|
id = str.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(str: String.SubSequence) {
|
||||||
|
self.init(str: String(str))
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(data: Data) {
|
||||||
|
guard let str = String(data: data, encoding: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(str: str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
|
extension Peer {
|
||||||
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
|
init(publicKey: Data) {
|
||||||
|
let digest = SHA256.hash(data: publicKey)
|
||||||
|
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
self.init(str: hex.prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||||
|
func toShort() -> Peer {
|
||||||
|
if id.count == Constants.maxIDLength, let data = Data(hexString: id) {
|
||||||
|
return Peer(publicKey: data)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
|
|||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
let readerID = readerIDData.hexEncodedString()
|
||||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
guard Peer(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate peer ID format using unified validator
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
return InputValidator.validatePeerID(peerID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
// MARK: - Enhanced Noise Session with Security
|
||||||
@@ -137,8 +132,8 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
// MARK: - Rate Limiter
|
// MARK: - Rate Limiter
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var handshakeTimestamps: [Peer: [Date]] = [:] // Peer -> timestamps
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var messageTimestamps: [Peer: [Date]] = [:] // Peer -> timestamps
|
||||||
|
|
||||||
// Global rate limiting
|
// Global rate limiting
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
@@ -146,7 +141,7 @@ final class NoiseRateLimiter {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
func allowHandshake(from peerID: String) -> Bool {
|
func allowHandshake(from peer: Peer) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
@@ -159,23 +154,23 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check per-peer rate limit
|
// Check per-peer rate limit
|
||||||
var timestamps = handshakeTimestamps[peerID] ?? []
|
var timestamps = handshakeTimestamps[peer] ?? []
|
||||||
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
timestamps = timestamps.filter { $0 > oneMinuteAgo }
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
if timestamps.count >= NoiseSecurityConstants.maxHandshakesPerMinute {
|
||||||
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
SecureLogger.warning("Per-peer handshake rate limit exceeded for \(peer.id): \(timestamps.count)/\(NoiseSecurityConstants.maxHandshakesPerMinute) per minute", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record new handshake
|
// Record new handshake
|
||||||
timestamps.append(now)
|
timestamps.append(now)
|
||||||
handshakeTimestamps[peerID] = timestamps
|
handshakeTimestamps[peer] = timestamps
|
||||||
globalHandshakeTimestamps.append(now)
|
globalHandshakeTimestamps.append(now)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowMessage(from peerID: String) -> Bool {
|
func allowMessage(from peer: Peer) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
@@ -188,26 +183,26 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check per-peer rate limit
|
// Check per-peer rate limit
|
||||||
var timestamps = messageTimestamps[peerID] ?? []
|
var timestamps = messageTimestamps[peer] ?? []
|
||||||
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
timestamps = timestamps.filter { $0 > oneSecondAgo }
|
||||||
|
|
||||||
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
if timestamps.count >= NoiseSecurityConstants.maxMessagesPerSecond {
|
||||||
SecureLogger.warning("Per-peer message rate limit exceeded for \(peerID): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
SecureLogger.warning("Per-peer message rate limit exceeded for \(peer.id): \(timestamps.count)/\(NoiseSecurityConstants.maxMessagesPerSecond) per second", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record new message
|
// Record new message
|
||||||
timestamps.append(now)
|
timestamps.append(now)
|
||||||
messageTimestamps[peerID] = timestamps
|
messageTimestamps[peer] = timestamps
|
||||||
globalMessageTimestamps.append(now)
|
globalMessageTimestamps.append(now)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset(for peerID: String) {
|
func reset(for peer: Peer) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
self.handshakeTimestamps.removeValue(forKey: peer)
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
self.messageTimestamps.removeValue(forKey: peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
return Peer(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
"wss://nostr21.com"
|
"wss://nostr21.com"
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
|
private static let defaultRelaySet = Set(defaultRelays)
|
||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
|
private var allowDefaultRelays: Bool = false
|
||||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
@@ -65,6 +67,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private let messageQueueLock = NSLock()
|
private let messageQueueLock = NSLock()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||||
|
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
@@ -78,29 +82,45 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize with default relays
|
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
allowDefaultRelays = hasMutual
|
||||||
|
if hasMutual {
|
||||||
|
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
||||||
|
}
|
||||||
// Deterministic JSON shape for outbound requests
|
// Deterministic JSON shape for outbound requests
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] favorites in
|
||||||
|
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to all configured relays
|
/// Connect to all configured relays
|
||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
guard networkService.activationAllowed else { return }
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
if shouldUseTor {
|
||||||
Task.detached {
|
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||||
let ready = await TorManager.shared.awaitReady()
|
Task.detached {
|
||||||
await MainActor.run {
|
let ready = await TorManager.shared.awaitReady()
|
||||||
if !ready {
|
await MainActor.run {
|
||||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
if !ready {
|
||||||
return
|
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||||
}
|
return
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
}
|
||||||
for relay in self.relays {
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||||
self.connectToRelay(relay.url)
|
for relay in self.relays {
|
||||||
|
self.connectToRelay(relay.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||||
|
for relay in self.relays {
|
||||||
|
connectToRelay(relay.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,8 +140,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
guard networkService.activationAllowed else { return }
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -130,22 +152,21 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let existing = Set(relays.map { $0.url })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in Set(relayUrls) {
|
for url in targets where !existing.contains(url) {
|
||||||
if !existing.contains(url) {
|
relays.append(Relay(url: url))
|
||||||
relays.append(Relay(url: url))
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
if connections[url] == nil {
|
for url in targets where connections[url] == nil {
|
||||||
connectToRelay(url)
|
connectToRelay(url)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
guard networkService.activationAllowed else { return }
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer sends until Tor is ready to avoid premature queueing
|
// Defer sends until Tor is ready to avoid premature queueing
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -154,7 +175,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let targetRelays = relayUrls ?? Self.defaultRelays
|
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||||
|
let targetRelays = allowedRelayList(from: requestedRelays)
|
||||||
|
guard !targetRelays.isEmpty else { return }
|
||||||
ensureConnections(to: targetRelays)
|
ensureConnections(to: targetRelays)
|
||||||
|
|
||||||
// Attempt immediate send to relays with active connections; queue the rest
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
@@ -220,7 +243,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
onEOSE: (() -> Void)? = nil
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
guard networkService.activationAllowed else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if messageHandlers[id] != nil {
|
if messageHandlers[id] != nil {
|
||||||
@@ -229,7 +252,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
subscribeCoalesce[id] = now
|
subscribeCoalesce[id] = now
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
@@ -257,32 +280,37 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||||
|
let urls = allowedRelayList(from: candidateUrls)
|
||||||
// Always queue subscriptions; sending happens when a relay reports connected
|
// Always queue subscriptions; sending happens when a relay reports connected
|
||||||
let existingSet = Set(relays.map { $0.url })
|
let existingSet = Set(relays.map { $0.url })
|
||||||
for url in urls where !existingSet.contains(url) {
|
for url in urls where !existingSet.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
}
|
}
|
||||||
for url in urls {
|
for url in candidateUrls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
map[id] = messageString
|
map[id] = messageString
|
||||||
self.pendingSubscriptions[url] = map
|
self.pendingSubscriptions[url] = map
|
||||||
}
|
}
|
||||||
// Initialize EOSE tracking if requested
|
// Initialize EOSE tracking if requested
|
||||||
if let onEOSE = onEOSE {
|
if let onEOSE = onEOSE {
|
||||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
if urls.isEmpty {
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
onEOSE()
|
||||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
} else {
|
||||||
Task { @MainActor in
|
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||||
guard let self = self else { return }
|
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||||
if let t = self.eoseTrackers[id] {
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
t.timer?.invalidate()
|
Task { @MainActor in
|
||||||
self.eoseTrackers.removeValue(forKey: id)
|
guard let self = self else { return }
|
||||||
onEOSE()
|
if let t = self.eoseTrackers[id] {
|
||||||
|
t.timer?.invalidate()
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
onEOSE()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
}
|
}
|
||||||
eoseTrackers[id] = tracker
|
|
||||||
}
|
}
|
||||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||||
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
// Ensure we actually have sockets opening to these relays so queued REQs can flush
|
||||||
@@ -298,6 +326,54 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateDefaultRelayPolicy(hasMutual: Bool) {
|
||||||
|
guard hasMutual != allowDefaultRelays else { return }
|
||||||
|
allowDefaultRelays = hasMutual
|
||||||
|
if hasMutual {
|
||||||
|
var existing = Set(relays.map { $0.url })
|
||||||
|
for url in Self.defaultRelays where !existing.contains(url) {
|
||||||
|
relays.append(Relay(url: url))
|
||||||
|
existing.insert(url)
|
||||||
|
}
|
||||||
|
if networkService.activationAllowed {
|
||||||
|
ensureConnections(to: Self.defaultRelays)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for url in Self.defaultRelays {
|
||||||
|
if let connection = connections[url] {
|
||||||
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
connections.removeValue(forKey: url)
|
||||||
|
subscriptions.removeValue(forKey: url)
|
||||||
|
}
|
||||||
|
messageQueueLock.lock()
|
||||||
|
for index in (0..<messageQueue.count).reversed() {
|
||||||
|
var item = messageQueue[index]
|
||||||
|
item.pendingRelays.subtract(Self.defaultRelaySet)
|
||||||
|
if item.pendingRelays.isEmpty {
|
||||||
|
messageQueue.remove(at: index)
|
||||||
|
} else {
|
||||||
|
messageQueue[index] = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
|
||||||
|
updateConnectionStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var result: [String] = []
|
||||||
|
for url in urls {
|
||||||
|
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||||
|
if seen.insert(url).inserted {
|
||||||
|
result.append(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/// Unsubscribe from a subscription
|
/// Unsubscribe from a subscription
|
||||||
func unsubscribe(id: String) {
|
func unsubscribe(id: String) {
|
||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
@@ -326,14 +402,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
if !TorManager.shared.isAutoStartAllowed() { return }
|
guard networkService.activationAllowed else { return }
|
||||||
guard let url = URL(string: urlString) else {
|
guard let url = URL(string: urlString) else {
|
||||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +424,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempting to connect to Nostr relay via the proxied session
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
// If Tor is enforced but not ready, delay connection until it is.
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let ready = await TorManager.shared.awaitReady()
|
||||||
@@ -534,7 +610,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
// If networking is disallowed, do not schedule reconnection
|
// If networking is disallowed, do not schedule reconnection
|
||||||
if !TorManager.shared.isAutoStartAllowed() {
|
if !networkService.activationAllowed {
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Peer ID Utilities
|
|
||||||
|
|
||||||
struct PeerIDUtils {
|
|
||||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
|
||||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: publicKey)
|
|
||||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
return String(hex.prefix(16))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -556,23 +556,13 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool {
|
func isPeerConnected(_ peerID: String) -> Bool {
|
||||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||||
let shortID: String = {
|
let shortID = Peer(str: peerID).toShort().id
|
||||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
|
||||||
}
|
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
|
return collectionsQueue.sync { peers[shortID]?.isConnected ?? false }
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPeerReachable(_ peerID: String) -> Bool {
|
func isPeerReachable(_ peerID: String) -> Bool {
|
||||||
// Accept both 16-hex short IDs and 64-hex Noise keys
|
// Accept both 16-hex short IDs and 64-hex Noise keys
|
||||||
let shortID: String = {
|
let shortID = Peer(str: peerID).toShort().id
|
||||||
if peerID.count == 64, let key = Data(hexString: peerID) {
|
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: key)
|
|
||||||
}
|
|
||||||
return peerID
|
|
||||||
}()
|
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
// Must be mesh-attached: at least one live direct link to the mesh
|
// Must be mesh-attached: at least one live direct link to the mesh
|
||||||
let meshAttached = peers.values.contains { $0.isConnected }
|
let meshAttached = peers.values.contains { $0.isConnected }
|
||||||
@@ -626,10 +616,11 @@ final class BLEService: NSObject {
|
|||||||
var payload = Data([NoisePayloadType.readReceipt.rawValue])
|
var payload = Data([NoisePayloadType.readReceipt.rawValue])
|
||||||
payload.append(contentsOf: receipt.originalMessageID.utf8)
|
payload.append(contentsOf: receipt.originalMessageID.utf8)
|
||||||
|
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
let peer = Peer(str: peerID)
|
||||||
|
if noiseService.hasEstablishedSession(with: peer) {
|
||||||
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
|
SecureLogger.debug("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", category: .session)
|
||||||
do {
|
do {
|
||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
let encrypted = try noiseService.encrypt(payload, for: peer)
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
@@ -653,7 +644,7 @@ final class BLEService: NSObject {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
||||||
}
|
}
|
||||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
if !noiseService.hasSession(with: peer) { initiateNoiseHandshake(with: peerID) }
|
||||||
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
|
SecureLogger.debug("🕒 Queued READ receipt for \(peerID) until handshake completes", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,13 +665,13 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
|
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
|
||||||
guard noiseService.hasSession(with: peerID) else {
|
guard noiseService.hasSession(with: Peer(str: peerID)) else {
|
||||||
// Lazy-handshake path: queue? For now, initiate handshake and drop
|
// Lazy-handshake path: queue? For now, initiate handshake and drop
|
||||||
initiateNoiseHandshake(with: peerID)
|
initiateNoiseHandshake(with: peerID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
|
let encrypted = try noiseService.encrypt(typedPayload, for: Peer(str: peerID))
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
@@ -715,9 +706,9 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
|
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
|
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState {
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) {
|
||||||
return .established
|
return .established
|
||||||
} else if noiseService.hasSession(with: peerID) {
|
} else if noiseService.hasSession(with: Peer(str: peerID)) {
|
||||||
return .handshaking
|
return .handshaking
|
||||||
} else {
|
} else {
|
||||||
return .none
|
return .none
|
||||||
@@ -848,7 +839,7 @@ final class BLEService: NSObject {
|
|||||||
SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
|
SecureLogger.debug("📨 Sending PM to \(recipientID): \(content.prefix(30))...", category: .session)
|
||||||
|
|
||||||
// Check if we have an established Noise session
|
// Check if we have an established Noise session
|
||||||
if noiseService.hasEstablishedSession(with: recipientID) {
|
if noiseService.hasEstablishedSession(with: Peer(str: recipientID)) {
|
||||||
// Encrypt and send
|
// Encrypt and send
|
||||||
do {
|
do {
|
||||||
// Create TLV-encoded private message
|
// Create TLV-encoded private message
|
||||||
@@ -862,7 +853,7 @@ final class BLEService: NSObject {
|
|||||||
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
|
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||||
messagePayload.append(tlvData)
|
messagePayload.append(tlvData)
|
||||||
|
|
||||||
let encrypted = try noiseService.encrypt(messagePayload, for: recipientID)
|
let encrypted = try noiseService.encrypt(messagePayload, for: Peer(str: recipientID))
|
||||||
|
|
||||||
// Convert recipientID to Data (assuming it's a hex string)
|
// Convert recipientID to Data (assuming it's a hex string)
|
||||||
var recipientData = Data()
|
var recipientData = Data()
|
||||||
@@ -928,10 +919,10 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
private func initiateNoiseHandshake(with peerID: String) {
|
private func initiateNoiseHandshake(with peerID: String) {
|
||||||
// Use NoiseEncryptionService for handshake
|
// Use NoiseEncryptionService for handshake
|
||||||
guard !noiseService.hasSession(with: peerID) else { return }
|
guard !noiseService.hasSession(with: Peer(str: peerID)) else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
let handshakeData = try noiseService.initiateHandshake(with: Peer(str: peerID))
|
||||||
|
|
||||||
// Send handshake init
|
// Send handshake init
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
@@ -981,7 +972,7 @@ final class BLEService: NSObject {
|
|||||||
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
|
var messagePayload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||||
messagePayload.append(tlvData)
|
messagePayload.append(tlvData)
|
||||||
|
|
||||||
let encrypted = try noiseService.encrypt(messagePayload, for: peerID)
|
let encrypted = try noiseService.encrypt(messagePayload, for: Peer(str: peerID))
|
||||||
|
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
@@ -1518,7 +1509,7 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Verify that the sender's derived ID from the announced noise public key matches the packet senderID
|
// Verify that the sender's derived ID from the announced noise public key matches the packet senderID
|
||||||
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
|
// This helps detect relayed or spoofed announces. Only warn in release; assert in debug.
|
||||||
let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey)
|
let derivedFromKey = Peer(publicKey: announcement.noisePublicKey).id
|
||||||
if derivedFromKey != peerID {
|
if derivedFromKey != peerID {
|
||||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: .security)
|
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: .security)
|
||||||
|
|
||||||
@@ -1730,7 +1721,7 @@ final class BLEService: NSObject {
|
|||||||
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
// Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix
|
||||||
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() {
|
||||||
// Find candidate identities by peerID prefix (16 hex)
|
// Find candidate identities by peerID prefix (16 hex)
|
||||||
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(Peer(str: peerID))
|
||||||
for candidate in candidates {
|
for candidate in candidates {
|
||||||
if let signingKey = candidate.signingPublicKey,
|
if let signingKey = candidate.signingPublicKey,
|
||||||
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
|
noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) {
|
||||||
@@ -1795,7 +1786,7 @@ final class BLEService: NSObject {
|
|||||||
recipientID.hexEncodedString() == myPeerID {
|
recipientID.hexEncodedString() == myPeerID {
|
||||||
// Handshake is for us
|
// Handshake is for us
|
||||||
do {
|
do {
|
||||||
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
|
if let response = try noiseService.processHandshakeMessage(from: Peer(str: peerID), message: packet.payload) {
|
||||||
// Send response
|
// Send response
|
||||||
let responsePacket = BitchatPacket(
|
let responsePacket = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
@@ -1815,7 +1806,7 @@ final class BLEService: NSObject {
|
|||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
SecureLogger.error("Failed to process handshake: \(error)")
|
||||||
// Try initiating a new handshake
|
// Try initiating a new handshake
|
||||||
if !noiseService.hasSession(with: peerID) {
|
if !noiseService.hasSession(with: Peer(str: peerID)) {
|
||||||
initiateNoiseHandshake(with: peerID)
|
initiateNoiseHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1840,7 +1831,7 @@ final class BLEService: NSObject {
|
|||||||
updatePeerLastSeen(peerID)
|
updatePeerLastSeen(peerID)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let decrypted = try noiseService.decrypt(packet.payload, from: peerID)
|
let decrypted = try noiseService.decrypt(packet.payload, from: Peer(str: peerID))
|
||||||
guard decrypted.count > 0 else { return }
|
guard decrypted.count > 0 else { return }
|
||||||
|
|
||||||
// First byte indicates the payload type
|
// First byte indicates the payload type
|
||||||
@@ -1880,7 +1871,7 @@ final class BLEService: NSObject {
|
|||||||
// We received an encrypted message before establishing a session with this peer.
|
// We received an encrypted message before establishing a session with this peer.
|
||||||
// Trigger a handshake so future messages can be decrypted.
|
// Trigger a handshake so future messages can be decrypted.
|
||||||
SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
|
SecureLogger.debug("🔑 Encrypted message from \(peerID) without session; initiating handshake")
|
||||||
if !noiseService.hasSession(with: peerID) {
|
if !noiseService.hasSession(with: Peer(str: peerID)) {
|
||||||
initiateNoiseHandshake(with: peerID)
|
initiateNoiseHandshake(with: peerID)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1985,9 +1976,9 @@ final class BLEService: NSObject {
|
|||||||
var payload = Data([NoisePayloadType.delivered.rawValue])
|
var payload = Data([NoisePayloadType.delivered.rawValue])
|
||||||
payload.append(contentsOf: messageID.utf8)
|
payload.append(contentsOf: messageID.utf8)
|
||||||
|
|
||||||
if noiseService.hasEstablishedSession(with: peerID) {
|
if noiseService.hasEstablishedSession(with: Peer(str: peerID)) {
|
||||||
do {
|
do {
|
||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
let encrypted = try noiseService.encrypt(payload, for: Peer(str: peerID))
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
@@ -2007,7 +1998,7 @@ final class BLEService: NSObject {
|
|||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload)
|
||||||
}
|
}
|
||||||
if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) }
|
if !noiseService.hasSession(with: Peer(str: peerID)) { initiateNoiseHandshake(with: peerID) }
|
||||||
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
|
SecureLogger.debug("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2022,7 +2013,7 @@ final class BLEService: NSObject {
|
|||||||
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
|
SecureLogger.debug("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", category: .session)
|
||||||
for payload in payloads {
|
for payload in payloads {
|
||||||
do {
|
do {
|
||||||
let encrypted = try noiseService.encrypt(payload, for: peerID)
|
let encrypted = try noiseService.encrypt(payload, for: Peer(str: peerID))
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.noiseEncrypted.rawValue,
|
type: MessageType.noiseEncrypted.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
|
|||||||
@@ -179,12 +179,11 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
func getFavoriteStatus(for peer: Peer) -> FavoriteRelationship? {
|
||||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
// Quick sanity: peer.id should be 16 hex chars (8 bytes)
|
||||||
guard peerID.count == 16 else { return nil }
|
guard peer.isShort else { return nil }
|
||||||
for (pubkey, rel) in favorites {
|
for (pubkey, rel) in favorites where Peer(publicKey: pubkey) == peer {
|
||||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
return rel
|
||||||
if derived == peerID { return rel }
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Foundation
|
|||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrTransport
|
private let nostr: NostrTransport
|
||||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
private var outbox: [Peer: [(content: String, nickname: String, messageID: String)]] = [:] // Peer -> queued messages
|
||||||
|
|
||||||
init(mesh: Transport, nostr: NostrTransport) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.mesh = mesh
|
self.mesh = mesh
|
||||||
@@ -21,80 +21,80 @@ final class MessageRouter {
|
|||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
let peer = Peer(publicKey: data)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle key updates
|
// Handle key updates
|
||||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
let peer = Peer(publicKey: newKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peer: Peer, recipientNickname: String, messageID: String) {
|
||||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
let reachableMesh = mesh.isPeerReachable(peer.id)
|
||||||
if reachableMesh {
|
if reachableMesh {
|
||||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peer.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// BLEService will initiate a handshake if needed and queue the message
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peer.id, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peer: peer) {
|
||||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via Nostr to \(peer.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peer.id, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peer] == nil { outbox[peer] = [] }
|
||||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
outbox[peer]?.append((content, recipientNickname, messageID))
|
||||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Queued PM for \(peer.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peer: Peer) {
|
||||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peer.id) {
|
||||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peer.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peer.id)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via Nostr to \(peer.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
nostr.sendReadReceipt(receipt, to: peerID)
|
nostr.sendReadReceipt(receipt, to: peer.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peer: Peer) {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peer.id) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peer.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peer.id)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peer.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peer: Peer, isFavorite: Bool) {
|
||||||
// Route via mesh when connected; else use Nostr
|
// Route via mesh when connected; else use Nostr
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerConnected(peer.id) {
|
||||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peer.id, isFavorite: isFavorite)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
nostr.sendFavoriteNotification(to: peer.id, isFavorite: isFavorite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peer: Peer) -> Bool {
|
||||||
// Two forms are supported:
|
// Two forms are supported:
|
||||||
// - 64-hex Noise public key (32 bytes)
|
// - 64-hex Noise public key (32 bytes)
|
||||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
if let noiseKey = peer.noiseKey {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if peerID.count == 16 {
|
} else if peer.isShort {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -102,18 +102,18 @@ final class MessageRouter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: String) {
|
func flushOutbox(for peer: Peer) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peer], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peer.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peer.id) {
|
||||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> mesh for \(peer.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peer.id, recipientNickname: nickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peer: peer) {
|
||||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> Nostr for \(peer.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peer.id, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Keep unsent items queued
|
// Keep unsent items queued
|
||||||
remaining.append((content, nickname, messageID))
|
remaining.append((content, nickname, messageID))
|
||||||
@@ -121,9 +121,9 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
// Persist only items we could not send
|
// Persist only items we could not send
|
||||||
if remaining.isEmpty {
|
if remaining.isEmpty {
|
||||||
outbox.removeValue(forKey: peerID)
|
outbox.removeValue(forKey: peer)
|
||||||
} else {
|
} else {
|
||||||
outbox[peerID] = remaining
|
outbox[peer] = remaining
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
static let shared = NetworkActivationService()
|
static let shared = NetworkActivationService()
|
||||||
|
|
||||||
@Published private(set) var activationAllowed: Bool = false
|
@Published private(set) var activationAllowed: Bool = false
|
||||||
|
@Published private(set) var userTorEnabled: Bool = true
|
||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
private var started = false
|
private var started = false
|
||||||
|
private let torPreferenceKey = "networkActivationService.userTorEnabled"
|
||||||
|
private var torAutoStartDesired: Bool = false
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@@ -20,9 +23,23 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
guard !started else { return }
|
guard !started else { return }
|
||||||
started = true
|
started = true
|
||||||
|
|
||||||
|
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
||||||
|
userTorEnabled = stored
|
||||||
|
} else {
|
||||||
|
userTorEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
// Initial compute
|
// Initial compute
|
||||||
activationAllowed = Self.computeAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
TorManager.shared.setAutoStartAllowed(activationAllowed)
|
activationAllowed = allowed
|
||||||
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
|
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
||||||
|
applyTorState(torDesired: torAutoStartDesired)
|
||||||
|
if allowed {
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
// React to location permission changes
|
// React to location permission changes
|
||||||
LocationChannelManager.shared.$permissionState
|
LocationChannelManager.shared.$permissionState
|
||||||
@@ -41,30 +58,56 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
|
guard enabled != userTorEnabled else { return }
|
||||||
|
userTorEnabled = enabled
|
||||||
|
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .TorUserPreferenceChanged,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["enabled": enabled]
|
||||||
|
)
|
||||||
|
reevaluate()
|
||||||
|
}
|
||||||
|
|
||||||
private func reevaluate() {
|
private func reevaluate() {
|
||||||
let allowed = Self.computeAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
if allowed != activationAllowed {
|
let torDesired = allowed && userTorEnabled
|
||||||
|
let statusChanged = allowed != activationAllowed
|
||||||
|
let torChanged = torDesired != torAutoStartDesired
|
||||||
|
if statusChanged {
|
||||||
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
|
||||||
activationAllowed = allowed
|
activationAllowed = allowed
|
||||||
TorManager.shared.setAutoStartAllowed(allowed)
|
}
|
||||||
if allowed {
|
if statusChanged || torChanged {
|
||||||
// Kick Tor + relays if we're now permitted
|
torAutoStartDesired = torDesired
|
||||||
TorManager.shared.startIfNeeded()
|
TorManager.shared.setAutoStartAllowed(torDesired)
|
||||||
// If app is in foreground, begin relay connections
|
applyTorState(torDesired: torDesired)
|
||||||
if TorManager.shared.isForeground() {
|
}
|
||||||
NostrRelayManager.shared.connect()
|
|
||||||
}
|
if allowed {
|
||||||
} else {
|
if torChanged {
|
||||||
// Transitioned to disallowed: disconnect relays and shut down Tor
|
// Reset relay sockets when switching transport path (Tor ↔︎ direct)
|
||||||
NostrRelayManager.shared.disconnect()
|
NostrRelayManager.shared.disconnect()
|
||||||
TorManager.shared.goDormantOnBackground()
|
|
||||||
}
|
}
|
||||||
|
NostrRelayManager.shared.connect()
|
||||||
|
} else if statusChanged {
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func computeAllowed() -> Bool {
|
private func basePolicyAllowed() -> Bool {
|
||||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
||||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
return permOK || hasMutual
|
return permOK || hasMutual
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyTorState(torDesired: Bool) {
|
||||||
|
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
||||||
|
if torDesired {
|
||||||
|
TorManager.shared.startIfNeeded()
|
||||||
|
} else {
|
||||||
|
TorManager.shared.shutdownCompletely()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ final class NoiseEncryptionService {
|
|||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
private var peerFingerprints: [Peer: String] = [:] // Peer -> fingerprint
|
||||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
private var fingerprintToPeer: [String: Peer] = [:] // fingerprint -> Peer
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
@@ -237,7 +237,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Set up session callbacks
|
// Set up session callbacks
|
||||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey in
|
||||||
self?.handleSessionEstablished(peerID: peerID, remoteStaticKey: remoteStaticKey)
|
self?.handleSessionEstablished(peer: Peer(str: peerID), remoteStaticKey: remoteStaticKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start session maintenance timer
|
// Start session maintenance timer
|
||||||
@@ -263,8 +263,8 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
func getPeerPublicKeyData(_ peer: Peer) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
return sessionManager.getRemoteStaticKey(for: peer.id)?.rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear persistent identity (for panic mode)
|
/// Clear persistent identity (for panic mode)
|
||||||
@@ -391,52 +391,52 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
func initiateHandshake(with peer: Peer) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peer.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peer.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
guard rateLimiter.allowHandshake(from: peer) else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
|
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peer.id)"))
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
SecureLogger.info(.handshakeStarted(peerID: peer.id))
|
||||||
|
|
||||||
// Return raw handshake data without wrapper
|
// Return raw handshake data without wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
let handshakeData = try sessionManager.initiateHandshake(with: peer.id)
|
||||||
return handshakeData
|
return handshakeData
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peer: Peer, message: Data) throws -> Data? {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peer.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peer.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
SecureLogger.warning(.handshakeFailed(peerID: peer.id, error: "Message too large"))
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
guard rateLimiter.allowHandshake(from: peer) else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peerID)"))
|
SecureLogger.warning(.authenticationFailed(peerID: "Rate limited: \(peer.id)"))
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
|
let responsePayload = try sessionManager.handleIncomingHandshake(from: peer.id, message: message)
|
||||||
|
|
||||||
|
|
||||||
// Return raw response without wrapper
|
// Return raw response without wrapper
|
||||||
@@ -444,117 +444,117 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
func hasEstablishedSession(with peer: Peer) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
return sessionManager.getSession(for: peer.id)?.isEstablished() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have a session (established or handshaking) with a peer
|
/// Check if we have a session (established or handshaking) with a peer
|
||||||
func hasSession(with peerID: String) -> Bool {
|
func hasSession(with peer: Peer) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
return sessionManager.getSession(for: peer.id) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
/// Encrypt data for a specific peer
|
/// Encrypt data for a specific peer
|
||||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
func encrypt(_ data: Data, for peer: Peer) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
guard rateLimiter.allowMessage(from: peerID) else {
|
guard rateLimiter.allowMessage(from: peer) else {
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have an established session
|
// Check if we have an established session
|
||||||
guard hasEstablishedSession(with: peerID) else {
|
guard hasEstablishedSession(with: peer) else {
|
||||||
// Signal that handshake is needed
|
// Signal that handshake is needed
|
||||||
onHandshakeRequired?(peerID)
|
onHandshakeRequired?(peer.id)
|
||||||
throw NoiseEncryptionError.handshakeRequired
|
throw NoiseEncryptionError.handshakeRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
return try sessionManager.encrypt(data, for: peerID)
|
return try sessionManager.encrypt(data, for: peer.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
func decrypt(_ data: Data, from peer: Peer) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
guard rateLimiter.allowMessage(from: peerID) else {
|
guard rateLimiter.allowMessage(from: peer) else {
|
||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have an established session
|
// Check if we have an established session
|
||||||
guard hasEstablishedSession(with: peerID) else {
|
guard hasEstablishedSession(with: peer) else {
|
||||||
throw NoiseEncryptionError.sessionNotEstablished
|
throw NoiseEncryptionError.sessionNotEstablished
|
||||||
}
|
}
|
||||||
|
|
||||||
return try sessionManager.decrypt(data, from: peerID)
|
return try sessionManager.decrypt(data, from: peer.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peer: Peer) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
return peerFingerprints[peer]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for a fingerprint
|
/// Get peer ID for a fingerprint
|
||||||
func getPeerID(for fingerprint: String) -> String? {
|
func getPeer(for fingerprint: String) -> Peer? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return fingerprintToPeerID[fingerprint]
|
return fingerprintToPeer[fingerprint]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a peer session
|
/// Remove a peer session
|
||||||
func removePeer(_ peerID: String) {
|
func removePeer(_ peer: Peer) {
|
||||||
sessionManager.removeSession(for: peerID)
|
sessionManager.removeSession(for: peer.id)
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
if let fingerprint = peerFingerprints[peerID] {
|
if let fingerprint = peerFingerprints[peer] {
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
fingerprintToPeer.removeValue(forKey: fingerprint)
|
||||||
}
|
}
|
||||||
peerFingerprints.removeValue(forKey: peerID)
|
peerFingerprints.removeValue(forKey: peer)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
SecureLogger.info(.sessionExpired(peerID: peer.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
peerFingerprints.removeAll()
|
peerFingerprints.removeAll()
|
||||||
fingerprintToPeerID.removeAll()
|
fingerprintToPeer.removeAll()
|
||||||
}
|
}
|
||||||
rateLimiter.resetAll()
|
rateLimiter.resetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peer: Peer, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
peerFingerprints[peerID] = fingerprint
|
peerFingerprints[peer] = fingerprint
|
||||||
fingerprintToPeerID[fingerprint] = peerID
|
fingerprintToPeer[fingerprint] = peer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peer.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peer.id, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ final class NostrTransport: Transport {
|
|||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.count == 16,
|
if peerID.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Peer(str: peerID)),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier)
|
sendLocalNotification(title: title, body: body, identifier: identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessageNotification(from sender: String, message: String, peerID: String) {
|
func sendPrivateMessageNotification(from sender: String, message: String, peer: Peer) {
|
||||||
let title = "🔒 DM from \(sender)"
|
let title = "🔒 DM from \(sender)"
|
||||||
let body = message
|
let body = message
|
||||||
let identifier = "private-\(UUID().uuidString)"
|
let identifier = "private-\(UUID().uuidString)"
|
||||||
let userInfo = ["peerID": peerID, "senderName": sender]
|
let userInfo = ["peerID": peer.id, "senderName": sender]
|
||||||
|
|
||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
senderPeerID: meshService.myPeerID,
|
senderPeer: Peer(str: meshService.myPeerID),
|
||||||
mentions: nil,
|
mentions: nil,
|
||||||
deliveryStatus: .sending
|
deliveryStatus: .sending
|
||||||
)
|
)
|
||||||
@@ -94,7 +94,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
|
|
||||||
/// Handle incoming private message
|
/// Handle incoming private message
|
||||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
func handleIncomingMessage(_ message: BitchatMessage) {
|
||||||
guard let senderPeerID = message.senderPeerID else { return }
|
guard let senderPeerID = message.senderPeer?.id else { return }
|
||||||
|
|
||||||
// Initialize chat if needed
|
// Initialize chat if needed
|
||||||
if privateChats[senderPeerID] == nil {
|
if privateChats[senderPeerID] == nil {
|
||||||
@@ -126,7 +126,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
NotificationService.shared.sendPrivateMessageNotification(
|
NotificationService.shared.sendPrivateMessageNotification(
|
||||||
from: message.sender,
|
from: message.sender,
|
||||||
message: message.content,
|
message: message.content,
|
||||||
peerID: senderPeerID
|
peer: Peer(str: senderPeerID)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -161,7 +161,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
for message in messages {
|
for message in messages {
|
||||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
if message.senderPeer?.id == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
sendReadReceipt(for: message)
|
sendReadReceipt(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,7 +214,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !sentReadReceipts.contains(message.id),
|
guard !sentReadReceipts.contains(message.id),
|
||||||
let senderPeerID = message.senderPeerID else {
|
let senderPeerID = message.senderPeer?.id else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
router.sendReadReceipt(receipt, to: Peer(str: senderPeerID))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: preserve previous behavior
|
// Fallback: preserve previous behavior
|
||||||
|
|||||||
@@ -539,6 +539,24 @@ final class TorManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shutdownCompletely() {
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
_ = tor_host_shutdown()
|
||||||
|
await MainActor.run {
|
||||||
|
self.isDormant = false
|
||||||
|
self.isReady = false
|
||||||
|
self.socksReady = false
|
||||||
|
self.bootstrapProgress = 0
|
||||||
|
self.bootstrapSummary = ""
|
||||||
|
self.isStarting = false
|
||||||
|
self.didStart = false
|
||||||
|
self.restarting = false
|
||||||
|
self.controlMonitorStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func restartTor() async {
|
private func restartTor() async {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
// Announce restart so UI can notify the user
|
// Announce restart so UI can notify the user
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ extension Notification.Name {
|
|||||||
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
|
||||||
static let TorWillRestart = Notification.Name("TorWillRestart")
|
static let TorWillRestart = Notification.Name("TorWillRestart")
|
||||||
static let TorWillStart = Notification.Name("TorWillStart")
|
static let TorWillStart = Notification.Name("TorWillStart")
|
||||||
|
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,43 +4,34 @@ import CFNetwork
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
|
||||||
/// when Tor is enforced/ready. Falls back to a default session only when
|
/// when Tor is enforced/ready. Allows swapping between proxied and direct
|
||||||
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
|
/// sessions so UI can toggle Tor usage at runtime.
|
||||||
final class TorURLSession {
|
final class TorURLSession {
|
||||||
static let shared = TorURLSession()
|
static let shared = TorURLSession()
|
||||||
|
|
||||||
// Default (no proxy) session for local development when dev bypass is enabled.
|
// Default (no proxy) session for direct Nostr access when Tor is disabled.
|
||||||
private var defaultSession: URLSession = {
|
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
|
||||||
let cfg = URLSessionConfiguration.default
|
|
||||||
cfg.waitsForConnectivity = true
|
|
||||||
return URLSession(configuration: cfg)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Proxied (SOCKS5) session that routes through Tor.
|
// Proxied (SOCKS5) session that routes through Tor.
|
||||||
private var torSession: URLSession = TorURLSession.makeTorSession()
|
private var torSession: URLSession = TorURLSession.makeTorSession()
|
||||||
|
private var useTorProxy: Bool = true
|
||||||
|
|
||||||
var session: URLSession {
|
var session: URLSession {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
useTorProxy ? torSession : defaultSession
|
||||||
// Dev bypass: use direct session. Call sites may still await Tor if desired.
|
|
||||||
return defaultSession
|
|
||||||
#else
|
|
||||||
// Production: always use the Tor-proxied session. Call sites ensure readiness.
|
|
||||||
return torSession
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
|
||||||
func rebuild() {
|
func rebuild() {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
defaultSession = TorURLSession.makeDefaultSession()
|
||||||
defaultSession = {
|
|
||||||
let cfg = URLSessionConfiguration.default
|
|
||||||
cfg.waitsForConnectivity = true
|
|
||||||
return URLSession(configuration: cfg)
|
|
||||||
}()
|
|
||||||
#endif
|
|
||||||
torSession = TorURLSession.makeTorSession()
|
torSession = TorURLSession.makeTorSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setProxyMode(useTor: Bool) {
|
||||||
|
guard useTorProxy != useTor else { return }
|
||||||
|
useTorProxy = useTor
|
||||||
|
rebuild()
|
||||||
|
}
|
||||||
|
|
||||||
private static func makeTorSession() -> URLSession {
|
private static func makeTorSession() -> URLSession {
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
cfg.waitsForConnectivity = true
|
cfg.waitsForConnectivity = true
|
||||||
@@ -63,4 +54,10 @@ final class TorURLSession {
|
|||||||
#endif
|
#endif
|
||||||
return URLSession(configuration: cfg)
|
return URLSession(configuration: cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeDefaultSession() -> URLSession {
|
||||||
|
let cfg = URLSessionConfiguration.default
|
||||||
|
cfg.waitsForConnectivity = true
|
||||||
|
return URLSession(configuration: cfg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ enum TransportConfig {
|
|||||||
static let uiWindowStepCount: Int = 200
|
static let uiWindowStepCount: Int = 200
|
||||||
|
|
||||||
// Share extension
|
// Share extension
|
||||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
|
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Send favorite notification to the peer via router (mesh or Nostr)
|
// Send favorite notification to the peer via router (mesh or Nostr)
|
||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
router.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
router.sendFavoriteNotification(to: Peer(str: peerID), isFavorite: !wasFavorite)
|
||||||
} else {
|
} else {
|
||||||
// Fallback to mesh-only if router not yet wired
|
// Fallback to mesh-only if router not yet wired
|
||||||
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
meshService.sendFavoriteNotification(to: peerID, isFavorite: !wasFavorite)
|
||||||
|
|||||||
@@ -10,27 +10,6 @@ struct InputValidator {
|
|||||||
static let maxNicknameLength = 50
|
static let maxNicknameLength = 50
|
||||||
static let maxMessageLength = 10_000
|
static let maxMessageLength = 10_000
|
||||||
static let maxReasonLength = 200
|
static let maxReasonLength = 200
|
||||||
static let maxPeerIDLength = 64
|
|
||||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer ID Validation
|
|
||||||
|
|
||||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
// Accept short routing IDs (exact 16-hex)
|
|
||||||
if PeerIDResolver.isShortID(peerID) { return true }
|
|
||||||
// If length equals short-hex length but isn't valid hex, reject
|
|
||||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
|
||||||
// Accept full Noise key hex (exact 64-hex)
|
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
|
||||||
// If length equals full key length but isn't valid hex, reject
|
|
||||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
|
||||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
|
||||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
return !peerID.isEmpty &&
|
|
||||||
peerID.count < Limits.maxPeerIDLength &&
|
|
||||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct PeerIDResolver {
|
|
||||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
|
||||||
static func toShortID(_ id: String) -> String {
|
|
||||||
if id.count == 64, let data = Data(hexString: id) {
|
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isShortID(_ id: String) -> Bool {
|
|
||||||
return id.count == 16 && Data(hexString: id) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
|
||||||
return id.count == 64 && Data(hexString: id) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -459,7 +459,7 @@ struct ContentView: View {
|
|||||||
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
selectedMessageSender = name
|
selectedMessageSender = name
|
||||||
} else {
|
} else {
|
||||||
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeer?.id == peerID && $0.sender != "system" })?.sender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
|
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
|
||||||
@@ -1372,7 +1372,7 @@ struct ContentView: View {
|
|||||||
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
!fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
// Fallback: resolve from persisted social identity via fingerprint mapping
|
// Fallback: resolve from persisted social identity via fingerprint mapping
|
||||||
if headerPeerID.count == 16 {
|
if headerPeerID.count == 16 {
|
||||||
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
let candidates = viewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(Peer(str: headerPeerID))
|
||||||
if let id = candidates.first,
|
if let id = candidates.first,
|
||||||
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
let social = viewModel.identityManager.getSocialIdentity(for: id.fingerprint) {
|
||||||
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
if let pet = social.localPetname, !pet.isEmpty { return pet }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct LocationChannelsSheet: View {
|
|||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@ObservedObject private var manager = LocationChannelManager.shared
|
@ObservedObject private var manager = LocationChannelManager.shared
|
||||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||||
|
@ObservedObject private var network = NetworkActivationService.shared
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var customGeohash: String = ""
|
@State private var customGeohash: String = ""
|
||||||
@@ -266,6 +267,7 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
// Footer action inside the list
|
// Footer action inside the list
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
|
torToggleSection
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openSystemLocationSettings()
|
openSystemLocationSettings()
|
||||||
}) {
|
}) {
|
||||||
@@ -409,8 +411,36 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Standardized Colors
|
// MARK: - TOR Toggle & Standardized Colors
|
||||||
extension LocationChannelsSheet {
|
extension LocationChannelsSheet {
|
||||||
|
private var torToggleBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { network.userTorEnabled },
|
||||||
|
set: { network.setUserTorEnabled($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var torToggleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Toggle(isOn: torToggleBinding) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("tor routing")
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
Text("hides your ip for location channels. recommended: on.")
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color.secondary.opacity(0.12))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
private var standardGreen: Color {
|
private var standardGreen: Color {
|
||||||
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
@@ -419,6 +449,34 @@ extension LocationChannelsSheet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct IRCToggleStyle: ToggleStyle {
|
||||||
|
let accent: Color
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
Button(action: { configuration.isOn.toggle() }) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
configuration.label
|
||||||
|
Spacer()
|
||||||
|
Text(configuration.isOn ? "on" : "off")
|
||||||
|
.textCase(.uppercase)
|
||||||
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coverage helpers
|
// MARK: - Coverage helpers
|
||||||
extension LocationChannelsSheet {
|
extension LocationChannelsSheet {
|
||||||
private func coverageString(forPrecision len: Int) -> String {
|
private func coverageString(forPrecision len: Int) -> String {
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||||
])
|
])
|
||||||
|
DispatchQueue.global().async {
|
||||||
processShare()
|
self.processShare()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Processing
|
// MARK: - Processing
|
||||||
@@ -163,7 +164,7 @@ final class ShareViewController: UIViewController {
|
|||||||
statusLabel.text = msg
|
statusLabel.text = msg
|
||||||
// Complete shortly after showing status
|
// Complete shortly after showing status
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
|
||||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "REMOTE123",
|
senderPeer: "REMOTE123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "PACKET123",
|
senderPeer: "PACKET123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: "TEST123",
|
senderPeer: "TEST123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
originalSender: message.sender,
|
originalSender: message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeerID,
|
senderPeerID: message.senderPeer?.id,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,7 +439,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeer?.id != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
@@ -451,7 +451,7 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeerID,
|
senderPeerID: message.senderPeer?.id,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -627,7 +627,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
guard message.senderPeerID != node.peerID else { return }
|
guard message.senderPeer?.id != node.peerID else { return }
|
||||||
|
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -638,7 +638,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeerID,
|
senderPeerID: message.senderPeer?.id,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ final class MockBLEService: NSObject {
|
|||||||
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
||||||
for neighbor in neighbors() {
|
for neighbor in neighbors() {
|
||||||
// Avoid immediate echo loopback to sender if known
|
// Avoid immediate echo loopback to sender if known
|
||||||
if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
|
if let sender = message.senderPeer?.id, sender == neighbor.peerID { continue }
|
||||||
var relay = packet
|
var relay = packet
|
||||||
relay.ttl = nextTTL
|
relay.ttl = nextTTL
|
||||||
neighbor.simulateIncomingPacket(relay)
|
neighbor.simulateIncomingPacket(relay)
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ final class BinaryProtocolTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(decodedMessage.content, message.content)
|
XCTAssertEqual(decodedMessage.content, message.content)
|
||||||
XCTAssertEqual(decodedMessage.sender, message.sender)
|
XCTAssertEqual(decodedMessage.sender, message.sender)
|
||||||
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
|
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeer?.id)
|
||||||
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
|
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
|
||||||
|
|
||||||
// Timestamp should be close (within 1 second due to conversion)
|
// Timestamp should be close (within 1 second due to conversion)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final class TestHelpers {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: recipientNickname,
|
recipientNickname: recipientNickname,
|
||||||
senderPeerID: senderPeerID,
|
senderPeer: senderPeerID,
|
||||||
mentions: mentions
|
mentions: mentions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user