Compare commits

...
16 Commits
26 changed files with 904 additions and 713 deletions
+1 -1
View File
@@ -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)
} }
} }
+23 -10
View File
@@ -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
) )
} }
+139
View File
@@ -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
}
}
+1 -1
View File
@@ -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),
+13 -18
View File
@@ -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)
} }
} }
+1 -1
View File
@@ -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
-14
View File
@@ -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))
}
}
+25 -34
View File
@@ -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
} }
+45 -45
View File
@@ -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
} }
} }
+48 -48
View File
@@ -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)
} }
} }
} }
+1 -1
View File
@@ -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
} }
+2 -2
View File
@@ -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)
} }
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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)
-21
View File
@@ -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
-20
View File
@@ -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
+2 -2
View File
@@ -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 }
+3 -3
View File
@@ -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
) )
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
) )
} }