mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae3339a1a5 | ||
|
|
ca18843bb0 | ||
|
|
593fd7d737 |
Generated
+1
@@ -337,6 +337,7 @@
|
|||||||
es,
|
es,
|
||||||
ar,
|
ar,
|
||||||
de,
|
de,
|
||||||
|
fa,
|
||||||
fr,
|
fr,
|
||||||
he,
|
he,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
@Published private(set) var messages: [BitchatMessage] = []
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
@Published private(set) var isUnread: Bool = false
|
@Published private(set) var isUnread: Bool = false
|
||||||
|
|
||||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
/// delivery-status lookup. Kept in sync on every mutation:
|
||||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
/// - tail append: single insert
|
||||||
/// instead of rewriting every surviving dictionary entry. This matters
|
/// - out-of-order insert: suffix reindex from the insertion point
|
||||||
/// after the 1337-message cap is reached, when every steady-state tail
|
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||||
/// append evicts one old row.
|
/// rebuild does not change the asymptotics, and trim only happens once
|
||||||
///
|
/// the cap (1337) is reached. Simple and correct beats the
|
||||||
/// Out-of-order inserts and middle removals still reindex only the
|
/// offset-tracking alternative here.
|
||||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
|
||||||
private var indexByMessageID: [String: Int] = [:]
|
private var indexByMessageID: [String: Int] = [:]
|
||||||
private var indexOffset = 0
|
|
||||||
|
|
||||||
fileprivate init(id: ConversationID, cap: Int) {
|
fileprivate init(id: ConversationID, cap: Int) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// timeline position (in-place updates like media progress reuse the
|
/// timeline position (in-place updates like media progress reuse the
|
||||||
/// original timestamp); a new message goes through ordered insertion.
|
/// original timestamp); a new message goes through ordered insertion.
|
||||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||||
if let index = physicalIndex(forMessageID: message.id) {
|
if let index = indexByMessageID[message.id] {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||||
/// Returns `true` when the status was applied.
|
/// Returns `true` when the status was applied.
|
||||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
let message = messages[index]
|
let message = messages[index]
|
||||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||||
|
|
||||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// observers still need an @Published emission to re-render.
|
/// observers still need an @Published emission to re-render.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
guard let index = indexByMessageID[messageID] else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
/// Removes a single message by ID. Returns the removed message, or
|
/// Removes a single message by ID. Returns the removed message, or
|
||||||
/// `nil` when no message with that ID exists.
|
/// `nil` when no message with that ID exists.
|
||||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
guard let index = indexByMessageID[messageID] else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
if index == 0 {
|
reindex(from: index)
|
||||||
indexOffset += 1
|
|
||||||
} else {
|
|
||||||
reindex(from: index)
|
|
||||||
}
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
for id in removedIDs {
|
for id in removedIDs {
|
||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
indexOffset = 0
|
|
||||||
reindex(from: 0)
|
reindex(from: 0)
|
||||||
return removedIDs
|
return removedIDs
|
||||||
}
|
}
|
||||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
fileprivate func clearMessages() {
|
fileprivate func clearMessages() {
|
||||||
messages.removeAll()
|
messages.removeAll()
|
||||||
indexByMessageID.removeAll()
|
indexByMessageID.removeAll()
|
||||||
indexOffset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Diagnostics
|
// MARK: Diagnostics
|
||||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
let message = messages[position]
|
let message = messages[position]
|
||||||
// Count equality + every message resolving to its own position
|
// Count equality + every message resolving to its own position
|
||||||
// proves the index is exactly the inverse map (no stale extras).
|
// proves the index is exactly the inverse map (no stale extras).
|
||||||
if let logicalIndex = indexByMessageID[message.id] {
|
if let index = indexByMessageID[message.id] {
|
||||||
let expectedIndex = indexOffset + position
|
if index != position {
|
||||||
if logicalIndex != expectedIndex {
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
|
|
||||||
private func reindex(from start: Int) {
|
private func reindex(from start: Int) {
|
||||||
for index in start..<messages.count {
|
for index in start..<messages.count {
|
||||||
indexByMessageID[messages[index].id] = indexOffset + index
|
indexByMessageID[messages[index].id] = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
|
||||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
|
||||||
let index = logicalIndex - indexOffset
|
|
||||||
guard messages.indices.contains(index) else { return nil }
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||||
private func trimIfNeeded() -> [String] {
|
private func trimIfNeeded() -> [String] {
|
||||||
guard messages.count > cap else { return [] }
|
guard messages.count > cap else { return [] }
|
||||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
indexOffset += overflow
|
reindex(from: 0)
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -860,8 +844,8 @@ extension Conversation {
|
|||||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||||
func _testCorruptIndexEntries() {
|
func _testCorruptIndexEntries() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
indexByMessageID[messages[0].id] = 1
|
||||||
indexByMessageID[messages[1].id] = indexOffset
|
indexByMessageID[messages[1].id] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -875,8 +859,8 @@ extension Conversation {
|
|||||||
func _testCorruptOrderingPreservingIndex() {
|
func _testCorruptOrderingPreservingIndex() {
|
||||||
guard messages.count >= 2 else { return }
|
guard messages.count >= 2 else { return }
|
||||||
messages.swapAt(0, messages.count - 1)
|
messages.swapAt(0, messages.count - 1)
|
||||||
indexByMessageID[messages[0].id] = indexOffset
|
indexByMessageID[messages[0].id] = 0
|
||||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
|||||||
extension Conversation {
|
extension Conversation {
|
||||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
indexByMessageID[message.id] = messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
|
|||||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
@Published private(set) var teleportedGeo: Set<String> = []
|
@Published private(set) var teleportedGeo: Set<String> = []
|
||||||
|
|
||||||
|
private let teleportedGeoCapacity: Int
|
||||||
|
private var teleportedGeoOrder: [String] = []
|
||||||
|
private let geoNicknameCapacity: Int
|
||||||
|
private var geoNicknameOrder: [String] = []
|
||||||
|
|
||||||
|
init(
|
||||||
|
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
|
||||||
|
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
|
||||||
|
) {
|
||||||
|
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||||
|
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
|
||||||
|
}
|
||||||
|
|
||||||
func setCurrentGeohash(_ geohash: String?) {
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
currentGeohash = geohash?.lowercased()
|
let normalized = geohash?.lowercased()
|
||||||
|
if currentGeohash != normalized {
|
||||||
|
// Presence markers are scoped to the active geohash channel.
|
||||||
|
clearTeleportedGeo()
|
||||||
|
clearGeoNicknames()
|
||||||
|
}
|
||||||
|
currentGeohash = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
guard geoNicknameCapacity > 0 else {
|
||||||
|
clearGeoNicknames()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
if geoNicknames[key] != nil {
|
||||||
|
geoNicknames[key] = nickname
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||||
|
geoNicknameOrder.removeFirst()
|
||||||
|
geoNicknames.removeValue(forKey: oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
geoNicknames[key] = nickname
|
||||||
|
geoNicknameOrder.append(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
geoNicknames = Dictionary(
|
guard geoNicknameCapacity > 0 else {
|
||||||
uniqueKeysWithValues: nicknames.map { key, value in
|
clearGeoNicknames()
|
||||||
(key.lowercased(), value)
|
return
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var ordered: [String] = []
|
||||||
|
var normalized: [String: String] = [:]
|
||||||
|
for (key, value) in nicknames {
|
||||||
|
let lower = key.lowercased()
|
||||||
|
guard seen.insert(lower).inserted else { continue }
|
||||||
|
ordered.append(lower)
|
||||||
|
normalized[lower] = value
|
||||||
|
}
|
||||||
|
if ordered.count > geoNicknameCapacity {
|
||||||
|
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||||
|
ordered = kept
|
||||||
|
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
|
||||||
|
normalized[key].map { (key, $0) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
geoNicknameOrder = ordered
|
||||||
|
geoNicknames = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearGeoNicknames() {
|
func clearGeoNicknames() {
|
||||||
geoNicknames.removeAll()
|
geoNicknames.removeAll()
|
||||||
|
geoNicknameOrder.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainGeoNicknames(keeping pubkeys: Set<String>) {
|
||||||
|
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
|
||||||
|
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func markTeleported(_ pubkeyHex: String) {
|
func markTeleported(_ pubkeyHex: String) {
|
||||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
guard teleportedGeoCapacity > 0 else {
|
||||||
|
clearTeleportedGeo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = pubkeyHex.lowercased()
|
||||||
|
guard !teleportedGeo.contains(key) else { return }
|
||||||
|
|
||||||
|
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||||
|
teleportedGeoOrder.removeFirst()
|
||||||
|
teleportedGeo.remove(oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
teleportedGeo.insert(key)
|
||||||
|
teleportedGeoOrder.append(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTeleported(_ pubkeyHex: String) {
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
let key = pubkeyHex.lowercased()
|
||||||
|
teleportedGeo.remove(key)
|
||||||
|
teleportedGeoOrder.removeAll { $0 == key }
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
guard teleportedGeoCapacity > 0 else {
|
||||||
|
clearTeleportedGeo()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen: Set<String> = []
|
||||||
|
var ordered: [String] = []
|
||||||
|
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||||
|
seen.insert(key)
|
||||||
|
ordered.append(key)
|
||||||
|
}
|
||||||
|
if ordered.count > teleportedGeoCapacity {
|
||||||
|
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||||
|
}
|
||||||
|
teleportedGeoOrder = ordered
|
||||||
|
teleportedGeo = Set(ordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||||
|
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||||
|
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearTeleportedGeo() {
|
func clearTeleportedGeo() {
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
|
teleportedGeoOrder.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset() {
|
func reset() {
|
||||||
currentGeohash = nil
|
currentGeohash = nil
|
||||||
geoNicknames.removeAll()
|
geoNicknames.removeAll()
|
||||||
|
geoNicknameOrder.removeAll()
|
||||||
teleportedGeo.removeAll()
|
teleportedGeo.removeAll()
|
||||||
|
teleportedGeoOrder.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3099
-1
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,9 @@ enum NoiseSecurityConstants {
|
|||||||
// Maximum handshake message size
|
// Maximum handshake message size
|
||||||
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
|
||||||
|
|
||||||
|
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||||
|
static let xxInitialMessageSize = 32
|
||||||
|
|
||||||
// Session timeout - sessions older than this should be renegotiated
|
// Session timeout - sessions older than this should be renegotiated
|
||||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ class NoiseSession {
|
|||||||
|
|
||||||
// Only initiator writes the first message
|
// Only initiator writes the first message
|
||||||
if role == .initiator {
|
if role == .initiator {
|
||||||
let message = try handshakeState!.writeMessage()
|
guard let handshake = handshakeState else {
|
||||||
|
throw NoiseSessionError.invalidState
|
||||||
|
}
|
||||||
|
let message = try handshake.writeMessage()
|
||||||
sentHandshakeMessages.append(message)
|
sentHandshakeMessages.append(message)
|
||||||
return message
|
return message
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
/// A responder rehandshake must not evict a working transport session
|
||||||
|
/// before the candidate proves that its authenticated static key belongs
|
||||||
|
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||||
|
/// until the XX handshake completes and the binding is validated.
|
||||||
|
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -54,6 +59,9 @@ final class NoiseSessionManager {
|
|||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
}
|
}
|
||||||
|
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +70,11 @@ final class NoiseSessionManager {
|
|||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
}
|
}
|
||||||
|
for (_, candidate) in responderCandidates {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +91,7 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
@@ -91,6 +104,7 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -100,39 +114,50 @@ final class NoiseSessionManager {
|
|||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
var shouldCreateNew = false
|
let session: NoiseSession
|
||||||
var existingSession: NoiseSession? = nil
|
let isReplacementCandidate: Bool
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
if let candidate = responderCandidates[peerID] {
|
||||||
// If we have an established session, the peer must have cleared their session
|
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
// but never the established session it is trying to replace.
|
||||||
// We should accept the new handshake to re-establish encryption
|
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
if existing.isEstablished() {
|
candidate.reset()
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
responderCandidates[peerID] = replacement
|
||||||
shouldCreateNew = true
|
session = replacement
|
||||||
} else {
|
} else {
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
session = candidate
|
||||||
// reset and start fresh (the other side may have restarted)
|
}
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
isReplacementCandidate = true
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
} else if let existing = sessions[peerID] {
|
||||||
shouldCreateNew = true
|
if existing.isEstablished() {
|
||||||
} else {
|
SecureLogger.info(
|
||||||
existingSession = existing
|
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||||
}
|
category: .session
|
||||||
|
)
|
||||||
|
let candidate = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = candidate
|
||||||
|
session = candidate
|
||||||
|
isReplacementCandidate = true
|
||||||
|
} else if existing.getState() == .handshaking,
|
||||||
|
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
// No established transport state exists to preserve. A
|
||||||
|
// fresh initiation replaces the incomplete handshake.
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existing.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
sessions[peerID] = replacement
|
||||||
|
session = replacement
|
||||||
|
isReplacementCandidate = false
|
||||||
|
} else {
|
||||||
|
session = existing
|
||||||
|
isReplacementCandidate = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
isReplacementCandidate = false
|
||||||
session = existingSession!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
// Process the handshake message within the synchronized block
|
||||||
@@ -141,18 +166,40 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
// Check if session is established after processing
|
// Check if session is established after processing
|
||||||
if session.isEstablished() {
|
if session.isEstablished() {
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||||
DispatchQueue.global().async { [weak self] in
|
throw NoiseSessionError.peerIdentityMismatch
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
}
|
||||||
|
|
||||||
|
if isReplacementCandidate {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
let previous = sessions.updateValue(session, forKey: peerID)
|
||||||
|
if let previous, previous !== session {
|
||||||
|
previous.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
} catch {
|
} catch {
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
// A failed candidate is discarded without touching the
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
// established session. Ordinary failed handshakes retain the
|
||||||
|
// historical cleanup behavior.
|
||||||
|
if isReplacementCandidate {
|
||||||
|
if let storedCandidate = responderCandidates[peerID],
|
||||||
|
storedCandidate === session {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
} else if let storedSession = sessions[peerID],
|
||||||
|
storedSession === session {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
session.reset()
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
DispatchQueue.global().async { [weak self] in
|
||||||
@@ -165,6 +212,24 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||||
|
/// also accepted by internal callers when they exactly match the static
|
||||||
|
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||||
|
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||||
|
private func authenticatedRemoteKey(
|
||||||
|
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
matches claimedPeerID: PeerID
|
||||||
|
) -> Bool {
|
||||||
|
let rawKey = remoteKey.rawRepresentation
|
||||||
|
if claimedPeerID.isShort {
|
||||||
|
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||||
|
}
|
||||||
|
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||||
|
return claimedNoiseKey == rawKey
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
|
|||||||
@@ -701,6 +701,10 @@ struct NostrEvent: Codable {
|
|||||||
throw NostrError.invalidEvent
|
throw NostrError.invalidEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard Self.isWithinInboundTagLimits(tags) else {
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
self.id = dict["id"] as? String ?? ""
|
self.id = dict["id"] as? String ?? ""
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.created_at = createdAt
|
self.created_at = createdAt
|
||||||
@@ -710,6 +714,21 @@ struct NostrEvent: Codable {
|
|||||||
self.sig = dict["sig"] as? String
|
self.sig = dict["sig"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bounds untrusted relay tag arrays so attackers cannot force large
|
||||||
|
/// allocations or expensive joins on the inbound hot path.
|
||||||
|
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||||
|
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||||
|
|
||||||
|
for tag in tags {
|
||||||
|
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||||
|
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||||
let (eventId, eventIdHash) = try calculateEventId()
|
let (eventId, eventIdHash) = try calculateEventId()
|
||||||
|
|
||||||
|
|||||||
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
|
|||||||
case notice(String)
|
case notice(String)
|
||||||
|
|
||||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||||
guard let data = message.data,
|
guard let data = message.dataWithinInboundLimit,
|
||||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||||
array.count >= 2,
|
array.count >= 2,
|
||||||
let type = array[0] as? String else {
|
let type = array[0] as? String else {
|
||||||
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension URLSessionWebSocketTask.Message {
|
private extension URLSessionWebSocketTask.Message {
|
||||||
var data: Data? {
|
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
||||||
|
/// where we can (string length), and always before JSON parse.
|
||||||
|
var dataWithinInboundLimit: Data? {
|
||||||
|
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
||||||
switch self {
|
switch self {
|
||||||
case .string(let text): text.data(using: .utf8)
|
case .string(let text):
|
||||||
case .data(let data): data
|
guard text.utf8.count <= maxBytes else { return nil }
|
||||||
@unknown default: nil
|
return text.data(using: .utf8)
|
||||||
|
case .data(let data):
|
||||||
|
guard data.count <= maxBytes else { return nil }
|
||||||
|
return data
|
||||||
|
@unknown default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ final class BLENoisePacketHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// Returns true when the handshake message was processed successfully.
|
||||||
|
/// Callers use this to distinguish an authenticated replacement completion
|
||||||
|
/// from a rejected candidate while an older session remains established.
|
||||||
|
@discardableResult
|
||||||
|
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Use NoiseEncryptionService for handshake processing
|
// Use NoiseEncryptionService for handshake processing
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||||
@@ -72,14 +76,26 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// which will send any pending messages at the right time
|
||||||
|
return true
|
||||||
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
|
// The candidate was already discarded by the session manager.
|
||||||
|
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||||
|
// handshake or recreate state for the attacker-selected ID.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
} 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 !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
|||||||
@@ -1618,7 +1618,44 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
|
/// Accept a leave only when the claimed sender proves possession of the
|
||||||
|
/// signing key bound by a verified announce. The persisted identity cache
|
||||||
|
/// keeps delayed/relayed leaves verifiable after the live registry entry
|
||||||
|
/// has aged out.
|
||||||
|
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
|
let registrySigningKey = collectionsQueue.sync {
|
||||||
|
peerRegistry.info(for: peerID)?.signingPublicKey
|
||||||
|
}
|
||||||
|
let verifiedViaRegistry = registrySigningKey.map {
|
||||||
|
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||||||
|
} ?? false
|
||||||
|
let verifiedViaPersistedIdentity = !verifiedViaRegistry
|
||||||
|
&& identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in
|
||||||
|
PeerID(publicKey: identity.publicKey) == peerID
|
||||||
|
&& identity.signingPublicKey.map {
|
||||||
|
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard verifiedViaRegistry || verifiedViaPersistedIdentity else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid departure retires transport state too; otherwise
|
||||||
|
// canDeliverSecurely could remain true for a peer we just removed.
|
||||||
|
noiseService.clearSession(for: peerID)
|
||||||
|
readLinkState { _ in
|
||||||
|
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||||||
|
owner == peerID ? link : nil
|
||||||
|
}
|
||||||
|
for link in departedLinks {
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = collectionsQueue.sync(flags: .barrier) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
// Remove the peer when they leave
|
// Remove the peer when they leave
|
||||||
peerRegistry.remove(peerID)
|
peerRegistry.remove(peerID)
|
||||||
@@ -1635,6 +1672,7 @@ final class BLEService: NSObject {
|
|||||||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
@@ -2336,6 +2374,12 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool {
|
||||||
|
bleQueue.sync {
|
||||||
|
noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peerRegistry.upsert(BLEPeerInfo(
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
@@ -4785,7 +4829,9 @@ extension BLEService {
|
|||||||
handleMeshPong(packet, from: senderID)
|
handleMeshPong(packet, from: senderID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
// A forged leave must neither evict the claimed peer nor spread
|
||||||
|
// to downstream nodes.
|
||||||
|
guard handleLeave(packet, from: senderID) else { return }
|
||||||
|
|
||||||
case .none:
|
case .none:
|
||||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||||
@@ -5426,8 +5472,14 @@ extension BLEService {
|
|||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
let processed = noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||||
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
|
let isEstablished = noiseService.hasEstablishedSession(with: peerID)
|
||||||
|
// XX message 1 is exactly the unauthenticated 32-byte ephemeral key.
|
||||||
|
// While replacing an existing session, do not authenticate its ingress
|
||||||
|
// link until a later message completes and validates the candidate.
|
||||||
|
let completedAuthenticatedHandshake = !wasEstablished
|
||||||
|
|| packet.payload.count != NoiseSecurityConstants.xxInitialMessageSize
|
||||||
|
if processed, isEstablished, completedAuthenticatedHandshake {
|
||||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content without special tokens, use plain formatting
|
// For very long content, use plain formatting to avoid expensive
|
||||||
let containsCashu = containsCashuToken(content)
|
// regex/detector work. Cashu presence must not disable this guard.
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
if content.isOversizedForRichFormatting() {
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ enum TransportConfig {
|
|||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
static let meshTimelineCap: Int = 1337
|
static let meshTimelineCap: Int = 1337
|
||||||
static let geoTimelineCap: Int = 1337
|
static let geoTimelineCap: Int = 1337
|
||||||
|
static let geoNicknameParticipantsCap: Int = 1337
|
||||||
static let contentLRUCap: Int = 2000
|
static let contentLRUCap: Int = 2000
|
||||||
static let geoSamplingEventLRUCap: Int = 2000
|
static let geoSamplingEventLRUCap: Int = 2000
|
||||||
|
|
||||||
@@ -81,6 +82,11 @@ enum TransportConfig {
|
|||||||
static let nostrDuplicateEventLogInterval: Int = 50
|
static let nostrDuplicateEventLogInterval: Int = 50
|
||||||
// Sample interval for per-event debug logs on the inbound hot path.
|
// Sample interval for per-event debug logs on the inbound hot path.
|
||||||
static let nostrInboundEventLogInterval: Int = 100
|
static let nostrInboundEventLogInterval: Int = 100
|
||||||
|
// Reject oversized/untrusted relay frames before JSON parse / store.
|
||||||
|
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
|
||||||
|
static let nostrMaxEventTags: Int = 64
|
||||||
|
static let nostrMaxEventTagValues: Int = 16
|
||||||
|
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||||
|
|
||||||
// Conversation store diagnostics (field observability)
|
// Conversation store diagnostics (field observability)
|
||||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||||
@@ -98,6 +104,12 @@ enum TransportConfig {
|
|||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
static let uiContentRateBucketCapacity: Double = 3
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||||
|
// Bound attacker-keyed bucket maps (sender IDs / content digests).
|
||||||
|
static let uiSenderRateBucketMaxEntries: Int = 2000
|
||||||
|
static let uiContentRateBucketMaxEntries: Int = 2000
|
||||||
|
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
|
||||||
|
// Cap teleported-participant markers so remote events cannot grow the set.
|
||||||
|
static let geoTeleportedParticipantsCap: Int = 1337
|
||||||
|
|
||||||
// UI sleeps/delays
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// In-app override for the UI language, on top of the system per-app
|
||||||
|
/// language. Apple resolves localization from the AppleLanguages default at
|
||||||
|
/// process start, so a new choice takes effect on the next launch — callers
|
||||||
|
/// surface a "restart to apply" note after changing it.
|
||||||
|
enum AppLanguageSettings {
|
||||||
|
/// "" means no override: follow the device (or per-app system) language.
|
||||||
|
static let overrideKey = "app.languageOverride"
|
||||||
|
private static let appleLanguagesKey = "AppleLanguages"
|
||||||
|
|
||||||
|
/// Language codes the app ships translations for, straight from the
|
||||||
|
/// built bundle so this never drifts from the string catalog.
|
||||||
|
static var availableLanguages: [String] {
|
||||||
|
Bundle.main.localizations
|
||||||
|
.filter { $0 != "Base" }
|
||||||
|
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The language's name in that language ("فارسی", "한국어") so every user
|
||||||
|
/// can find their own entry regardless of the current UI language.
|
||||||
|
static func endonym(for code: String) -> String {
|
||||||
|
let locale = Locale(identifier: code)
|
||||||
|
let name = locale.localizedString(forIdentifier: code) ?? code
|
||||||
|
return name.lowercased(with: locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the override (nil clears it). AppleLanguages drives the
|
||||||
|
/// actual localization lookup on next launch.
|
||||||
|
static func setOverride(_ code: String?) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let code, !code.isEmpty {
|
||||||
|
defaults.set(code, forKey: overrideKey)
|
||||||
|
defaults.set([code], forKey: appleLanguagesKey)
|
||||||
|
} else {
|
||||||
|
defaults.removeObject(forKey: overrideKey)
|
||||||
|
defaults.removeObject(forKey: appleLanguagesKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
|
|||||||
let content = message.content
|
let content = message.content
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let containsCashuEarly: Bool = {
|
|
||||||
let regex = Patterns.quickCashuPresence
|
|
||||||
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
|
||||||
}()
|
|
||||||
|
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
if content.isOversizedForRichFormatting() {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
plainStyle.foregroundColor = baseColor
|
plainStyle.foregroundColor = baseColor
|
||||||
plainStyle.font = isSelf
|
plainStyle.font = isSelf
|
||||||
|
|||||||
@@ -1183,6 +1183,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
identityManager.clearAllIdentityData()
|
identityManager.clearAllIdentityData()
|
||||||
peerIdentityStore.clearAll()
|
peerIdentityStore.clearAll()
|
||||||
locationPresenceStore.reset()
|
locationPresenceStore.reset()
|
||||||
|
publicRateLimiter.reset()
|
||||||
|
|
||||||
// Clear persistent favorites from keychain
|
// Clear persistent favorites from keychain
|
||||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ private extension ChatViewModelBootstrapper {
|
|||||||
viewModel?.objectWillChange.send()
|
viewModel?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &viewModel.cancellables)
|
.store(in: &viewModel.cancellables)
|
||||||
|
|
||||||
|
viewModel.participantTracker.$visiblePeople
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak viewModel] people in
|
||||||
|
Task { @MainActor [weak viewModel] in
|
||||||
|
let visible = Set(people.map { $0.id })
|
||||||
|
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
|
||||||
|
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &viewModel.cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPersistedViewState() {
|
func loadPersistedViewState() {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
|
||||||
|
now.timeIntervalSince(lastRefill) >= idleTTL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
private var senderBuckets: [String: TokenBucket] = [:]
|
||||||
@@ -35,17 +39,26 @@ struct MessageRateLimiter {
|
|||||||
private let senderRefill: Double
|
private let senderRefill: Double
|
||||||
private let contentCapacity: Double
|
private let contentCapacity: Double
|
||||||
private let contentRefill: Double
|
private let contentRefill: Double
|
||||||
|
private let maxSenderBuckets: Int
|
||||||
|
private let maxContentBuckets: Int
|
||||||
|
private let bucketIdleTTL: TimeInterval
|
||||||
|
|
||||||
init(
|
init(
|
||||||
senderCapacity: Double,
|
senderCapacity: Double,
|
||||||
senderRefillPerSec: Double,
|
senderRefillPerSec: Double,
|
||||||
contentCapacity: Double,
|
contentCapacity: Double,
|
||||||
contentRefillPerSec: Double
|
contentRefillPerSec: Double,
|
||||||
|
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
|
||||||
|
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
|
||||||
|
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
|
||||||
) {
|
) {
|
||||||
self.senderCapacity = senderCapacity
|
self.senderCapacity = senderCapacity
|
||||||
self.senderRefill = senderRefillPerSec
|
self.senderRefill = senderRefillPerSec
|
||||||
self.contentCapacity = contentCapacity
|
self.contentCapacity = contentCapacity
|
||||||
self.contentRefill = contentRefillPerSec
|
self.contentRefill = contentRefillPerSec
|
||||||
|
self.maxSenderBuckets = max(1, maxSenderBuckets)
|
||||||
|
self.maxContentBuckets = max(1, maxContentBuckets)
|
||||||
|
self.bucketIdleTTL = bucketIdleTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||||
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
|
|||||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||||
senderAllowed = true
|
senderAllowed = true
|
||||||
} else {
|
} else {
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
var senderBucket = Self.bucket(
|
||||||
|
for: senderKey,
|
||||||
|
in: &senderBuckets,
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
tokens: senderCapacity,
|
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxSenderBuckets,
|
||||||
|
idleTTL: bucketIdleTTL,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
senderAllowed = senderBucket.allow(now: now)
|
senderAllowed = senderBucket.allow(now: now)
|
||||||
senderBuckets[senderKey] = senderBucket
|
senderBuckets[senderKey] = senderBucket
|
||||||
}
|
}
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
// Rejected senders must not mint attacker-keyed content entries.
|
||||||
|
guard senderAllowed else { return false }
|
||||||
|
|
||||||
|
var contentBucket = Self.bucket(
|
||||||
|
for: contentKey,
|
||||||
|
in: &contentBuckets,
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
tokens: contentCapacity,
|
|
||||||
refillPerSec: contentRefill,
|
refillPerSec: contentRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxContentBuckets,
|
||||||
|
idleTTL: bucketIdleTTL,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
contentBuckets[contentKey] = contentBucket
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
return senderAllowed && contentAllowed
|
return contentAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
senderBuckets.removeAll()
|
||||||
|
contentBuckets.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
var bucketCountsForTesting: (sender: Int, content: Int) {
|
||||||
|
(senderBuckets.count, contentBuckets.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static so we can take `inout` on a stored dictionary without overlapping
|
||||||
|
// exclusive access through a mutating method on `self`.
|
||||||
|
private static func bucket(
|
||||||
|
for key: String,
|
||||||
|
in buckets: inout [String: TokenBucket],
|
||||||
|
capacity: Double,
|
||||||
|
refillPerSec: Double,
|
||||||
|
maxBuckets: Int,
|
||||||
|
idleTTL: TimeInterval,
|
||||||
|
now: Date
|
||||||
|
) -> TokenBucket {
|
||||||
|
if let existing = buckets[key] {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
|
||||||
|
return TokenBucket(
|
||||||
|
capacity: capacity,
|
||||||
|
tokens: capacity,
|
||||||
|
refillPerSec: refillPerSec,
|
||||||
|
lastRefill: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func evictIfNeeded(
|
||||||
|
from buckets: inout [String: TokenBucket],
|
||||||
|
maxBuckets: Int,
|
||||||
|
idleTTL: TimeInterval,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
guard buckets.count >= maxBuckets else { return }
|
||||||
|
|
||||||
|
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
|
||||||
|
guard buckets.count >= maxBuckets else { return }
|
||||||
|
|
||||||
|
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
|
||||||
|
buckets.removeValue(forKey: oldestKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ final class NostrInboundPipeline {
|
|||||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
geoEventLogCount += 1
|
geoEventLogCount += 1
|
||||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
SecureLogger.debug(
|
||||||
|
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct AppInfoView: View {
|
|||||||
/// introduction), and afterwards the sheet reopens wherever it was left.
|
/// introduction), and afterwards the sheet reopens wherever it was left.
|
||||||
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
||||||
@State private var showPanicConfirmation = false
|
@State private var showPanicConfirmation = false
|
||||||
|
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
|
||||||
|
/// The override changed this session; localization resolves at process
|
||||||
|
/// start, so surface the restart hint.
|
||||||
|
@State private var showLanguageRestartNote = false
|
||||||
|
|
||||||
private enum Pane: String {
|
private enum Pane: String {
|
||||||
case settings
|
case settings
|
||||||
@@ -55,6 +59,11 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
|
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
|
||||||
|
|
||||||
|
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
|
||||||
|
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
|
||||||
|
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
|
||||||
|
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
|
||||||
|
|
||||||
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
|
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
|
||||||
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
|
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
|
||||||
static func bridgeCell(_ cell: String) -> String {
|
static func bridgeCell(_ cell: String) -> String {
|
||||||
@@ -313,6 +322,52 @@ struct AppInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Language — an in-app override so the UI language can differ
|
||||||
|
// from the device language (takes effect on next launch).
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
SectionHeader(verbatim: Strings.Settings.languageTitle)
|
||||||
|
|
||||||
|
settingsCard {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
selectLanguage(nil)
|
||||||
|
} label: {
|
||||||
|
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
|
||||||
|
Button {
|
||||||
|
selectLanguage(code)
|
||||||
|
} label: {
|
||||||
|
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(Strings.Settings.languagePickerLabel)
|
||||||
|
.bitchatFont(size: 12, weight: .semibold)
|
||||||
|
.foregroundColor(textColor)
|
||||||
|
Spacer()
|
||||||
|
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
|
||||||
|
.bitchatFont(size: 12)
|
||||||
|
.foregroundColor(palette.accent)
|
||||||
|
Image(systemName: "chevron.up.chevron.down")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if showLanguageRestartNote {
|
||||||
|
Text(Strings.Settings.languageRestartNote)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(secondaryTextColor)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Voice — same card + IRC pill as every other toggle setting.
|
// Voice — same card + IRC pill as every other toggle setting.
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
SectionHeader(Strings.Voice.title)
|
SectionHeader(Strings.Voice.title)
|
||||||
@@ -458,6 +513,24 @@ struct AppInfoView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func selectLanguage(_ code: String?) {
|
||||||
|
let previous = languageOverride
|
||||||
|
AppLanguageSettings.setOverride(code)
|
||||||
|
languageOverride = code ?? ""
|
||||||
|
if languageOverride != previous {
|
||||||
|
showLanguageRestartNote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
|
||||||
|
HStack {
|
||||||
|
Text(title)
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var bridgeToggleBinding: Binding<Bool> {
|
private var bridgeToggleBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { bridgeService.isEnabled },
|
get: { bridgeService.isEnabled },
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ struct TextMessageView: View {
|
|||||||
// first text line; a fixed top padding left the lock's solid body
|
// first text line; a fixed top padding left the lock's solid body
|
||||||
// hanging below the line's visual center.
|
// hanging below the line's visual center.
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let isLong = message.content.isLongForDisplay()
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
if message.isPrivate {
|
if message.isPrivate {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
@@ -103,7 +103,7 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
if message.content.isLongForDisplay() {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
Button(labelKey) {
|
Button(labelKey) {
|
||||||
|
|||||||
@@ -21,6 +21,26 @@ extension String {
|
|||||||
return current >= threshold
|
return current >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the message should collapse behind Show more in the UI.
|
||||||
|
/// Length alone decides this — embedding a Cashu-looking token must not
|
||||||
|
/// disable the guard (remote DoS via unbounded layout).
|
||||||
|
func isLongForDisplay(
|
||||||
|
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
|
||||||
|
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
|
||||||
|
) -> Bool {
|
||||||
|
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when rich formatting (regex / link detectors) should be skipped.
|
||||||
|
/// Cashu presence used to exempt oversized content from the plain path;
|
||||||
|
/// that let untrusted input force expensive formatting work.
|
||||||
|
func isOversizedForRichFormatting(
|
||||||
|
lengthThreshold: Int = 4000,
|
||||||
|
tokenThreshold: Int = 1024
|
||||||
|
) -> Bool {
|
||||||
|
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
||||||
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
||||||
// form matches too — the token embedded after the scheme is the match.
|
// form matches too — the token embedded after the scheme is the match.
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
"comment" : "Fallback title when saving a shared link"
|
"comment" : "Fallback title when saving a shared link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "پیوند اشتراکگذاریشده"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -233,6 +239,12 @@
|
|||||||
"comment" : "Shown when the share payload cannot be encoded"
|
"comment" : "Shown when the share payload cannot be encoded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "کدگذاری پیوند ناموفق بود"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -428,6 +440,12 @@
|
|||||||
"comment" : "Shown when provided content cannot be shared"
|
"comment" : "Shown when provided content cannot be shared"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "محتوای قابل اشتراکگذاری وجود ندارد"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -623,6 +641,12 @@
|
|||||||
"comment" : "Shown when the share extension receives no content"
|
"comment" : "Shown when the share extension receives no content"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "چیزی برای اشتراکگذاری نیست"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -818,6 +842,12 @@
|
|||||||
"comment" : "Confirmation after successfully sharing a link"
|
"comment" : "Confirmation after successfully sharing a link"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
@@ -1013,6 +1043,12 @@
|
|||||||
"comment" : "Confirmation after successfully sharing text"
|
"comment" : "Confirmation after successfully sharing text"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fa" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fil" : {
|
"fil" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "needs_review",
|
"state" : "needs_review",
|
||||||
|
|||||||
@@ -147,6 +147,44 @@ struct AppArchitectureTests {
|
|||||||
#expect(store.teleportedGeo.isEmpty)
|
#expect(store.teleportedGeo.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
|
||||||
|
@MainActor
|
||||||
|
func locationPresenceStoreBoundsTeleportedParticipants() {
|
||||||
|
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruy")
|
||||||
|
store.markTeleported("AAAAAA")
|
||||||
|
store.markTeleported("BBBBBB")
|
||||||
|
store.markTeleported("CCCCCC")
|
||||||
|
|
||||||
|
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
|
||||||
|
|
||||||
|
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
|
||||||
|
#expect(store.teleportedGeo == Set(["cccccc"]))
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruz")
|
||||||
|
#expect(store.teleportedGeo.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
|
||||||
|
@MainActor
|
||||||
|
func locationPresenceStoreBoundsGeoNicknames() {
|
||||||
|
let store = LocationPresenceStore(geoNicknameCapacity: 2)
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruy")
|
||||||
|
store.setNickname("alice", for: "AAAAAA")
|
||||||
|
store.setNickname("bob", for: "BBBBBB")
|
||||||
|
store.setNickname("carol", for: "CCCCCC")
|
||||||
|
|
||||||
|
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
|
||||||
|
|
||||||
|
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
|
||||||
|
#expect(store.geoNicknames == ["cccccc": "carol"])
|
||||||
|
|
||||||
|
store.setCurrentGeohash("u4pruz")
|
||||||
|
#expect(store.geoNicknames.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||||
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||||
|
|||||||
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
|
||||||
|
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
||||||
|
ble._test_handlePacket(
|
||||||
|
unsigned,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!unsignedRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
|
||||||
|
let badSignature = try #require(
|
||||||
|
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
badSignature,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!badSignatureRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Establish a real session so the leave regression also verifies that
|
||||||
|
// stale secure-delivery state is retired, not just the peer-list row.
|
||||||
|
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||||
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||||
|
let centralUUID = "central-valid-leave"
|
||||||
|
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
||||||
|
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
||||||
|
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let signedLeave = try #require(
|
||||||
|
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
signedLeave,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let evicted = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
||||||
|
&& !ble.canDeliverSecurely(to: alicePeerID)
|
||||||
|
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(evicted)
|
||||||
|
let relayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(relayed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
@@ -690,6 +779,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.leave.rawValue,
|
||||||
|
senderID: Data(hexString: sender.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data(marker.utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
private(set) var publicMessages: [BitchatMessage] = []
|
||||||
|
|||||||
@@ -646,6 +646,25 @@ struct ChatViewModelFormattingTests {
|
|||||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func formatMessageAsText_longCashuFallsBackToPlain() async {
|
||||||
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "fmt-long-cashu",
|
||||||
|
sender: "Alice#a1b2",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: PeerID(str: "00000000000000b3")
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func formatMessageHeader_formatsSenderHeader() async {
|
func formatMessageHeader_formatsSenderHeader() async {
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
let (viewModel, _) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -45,154 +45,6 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deliberately simple O(n) model used to differentially test the store's
|
|
||||||
/// optimized logical-index bookkeeping. It models observable behavior only;
|
|
||||||
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
|
||||||
private struct ReferenceConversationTimeline {
|
|
||||||
struct Message: Equatable {
|
|
||||||
let id: String
|
|
||||||
let timestamp: Date
|
|
||||||
let content: String
|
|
||||||
var deliveryStatus: DeliveryStatus?
|
|
||||||
|
|
||||||
init(_ message: BitchatMessage) {
|
|
||||||
id = message.id
|
|
||||||
timestamp = message.timestamp
|
|
||||||
content = message.content
|
|
||||||
deliveryStatus = message.deliveryStatus
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppendResult {
|
|
||||||
let inserted: Bool
|
|
||||||
let trimmedCount: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
let cap: Int
|
|
||||||
private(set) var messages: [Message] = []
|
|
||||||
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
messages.contains { $0.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
|
||||||
guard !contains(message.id) else {
|
|
||||||
return AppendResult(inserted: false, trimmedCount: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = Message(message)
|
|
||||||
var low = 0
|
|
||||||
var high = messages.count
|
|
||||||
while low < high {
|
|
||||||
let mid = (low + high) / 2
|
|
||||||
if messages[mid].timestamp <= snapshot.timestamp {
|
|
||||||
low = mid + 1
|
|
||||||
} else {
|
|
||||||
high = mid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages.insert(snapshot, at: low)
|
|
||||||
|
|
||||||
let overflow = max(0, messages.count - cap)
|
|
||||||
if overflow > 0 {
|
|
||||||
messages.removeFirst(overflow)
|
|
||||||
}
|
|
||||||
return AppendResult(inserted: true, trimmedCount: overflow)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func upsert(_ message: BitchatMessage) -> Int {
|
|
||||||
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
messages[index] = Message(message)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return append(message).trimmedCount
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
|
||||||
guard let index = messages.firstIndex(where: { $0.id == id }),
|
|
||||||
messages[index].deliveryStatus != status else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// The differential stream uses only unique `.delivered` values (or
|
|
||||||
// an exact repeat), so no-downgrade policy is intentionally outside
|
|
||||||
// this index-focused reference model.
|
|
||||||
messages[index].deliveryStatus = status
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func remove(at index: Int) -> Message {
|
|
||||||
messages.remove(at: index)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func removeAll(where predicate: (Message) -> Bool) {
|
|
||||||
messages.removeAll(where: predicate)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func clear() {
|
|
||||||
messages.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ConversationStoreDifferentialRNG {
|
|
||||||
private var state: UInt64
|
|
||||||
|
|
||||||
init(seed: UInt64) {
|
|
||||||
state = seed
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func next() -> UInt64 {
|
|
||||||
state &+= 0x9E37_79B9_7F4A_7C15
|
|
||||||
var value = state
|
|
||||||
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
|
||||||
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
|
||||||
return value ^ (value >> 31)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func index(upperBound: Int) -> Int {
|
|
||||||
precondition(upperBound > 0)
|
|
||||||
return Int(next() % UInt64(upperBound))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func expectStore(
|
|
||||||
_ store: ConversationStore,
|
|
||||||
matches reference: ReferenceConversationTimeline,
|
|
||||||
issuedIDs: [String],
|
|
||||||
checkpoint: String
|
|
||||||
) {
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
|
||||||
|
|
||||||
let lookupSnapshot = reference.messages.compactMap { expected in
|
|
||||||
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
|
||||||
}
|
|
||||||
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
|
||||||
#expect(
|
|
||||||
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
|
||||||
"per-conversation ID set mismatch at \(checkpoint)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
|
||||||
let id = reference.messages[index].id
|
|
||||||
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeIDs = Set(reference.messages.map(\.id))
|
|
||||||
var checkedStaleIDs = 0
|
|
||||||
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
|
||||||
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
|
||||||
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
|
||||||
checkedStaleIDs += 1
|
|
||||||
if checkedStaleIDs == 16 { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
|
||||||
@MainActor
|
|
||||||
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let conversation = store.conversation(for: .mesh)
|
|
||||||
let overflow = 64
|
|
||||||
|
|
||||||
for i in 0..<(conversation.cap + overflow) {
|
|
||||||
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
|
||||||
}
|
|
||||||
|
|
||||||
#expect(conversation.messages.first?.id == "m\(overflow)")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
|
||||||
|
|
||||||
// Exercise a suffix reindex after the head offset has advanced, then
|
|
||||||
// trim the old head. The late row becomes the new first element.
|
|
||||||
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
|
||||||
#expect(store.append(late, to: .mesh))
|
|
||||||
#expect(conversation.messages.first?.id == "late")
|
|
||||||
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
|
||||||
|
|
||||||
// Head and middle removals, an in-place upsert, and a status update
|
|
||||||
// must all resolve through the same logical index representation.
|
|
||||||
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
|
||||||
let middleID = "m\(overflow + conversation.cap / 2)"
|
|
||||||
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
|
||||||
|
|
||||||
let probeID = "m\(overflow + 10)"
|
|
||||||
store.upsertByID(
|
|
||||||
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
|
||||||
in: .mesh
|
|
||||||
)
|
|
||||||
#expect(conversation.message(withID: probeID)?.content == "edited")
|
|
||||||
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
|
||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
|
|
||||||
// Clearing resets the logical offset as well as the maps.
|
|
||||||
store.clear(.mesh)
|
|
||||||
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
|
||||||
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
|
||||||
#expect(store.auditInvariants().isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("logical index offset matches a reference model under adversarial mutations")
|
|
||||||
@MainActor
|
|
||||||
func logicalIndexOffsetDifferentialStress() async {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = store.conversation(for: .mesh).cap
|
|
||||||
var reference = ReferenceConversationTimeline(cap: cap)
|
|
||||||
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
|
||||||
var issuedIDs: [String] = []
|
|
||||||
var nextID = 0
|
|
||||||
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
|
||||||
var trimmedCount = 0
|
|
||||||
|
|
||||||
var tailAppendCount = 0
|
|
||||||
var outOfOrderCount = 0
|
|
||||||
var duplicateOrReuseCount = 0
|
|
||||||
var headRemovalCount = 0
|
|
||||||
var middleRemovalCount = 0
|
|
||||||
var upsertCount = 0
|
|
||||||
var deliveryUpdateCount = 0
|
|
||||||
var filterCount = 0
|
|
||||||
var clearCount = 0
|
|
||||||
|
|
||||||
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
|
||||||
let number = nextID
|
|
||||||
nextID += 1
|
|
||||||
let id = "diff-\(number)"
|
|
||||||
issuedIDs.append(id)
|
|
||||||
let resolvedTimestamp: TimeInterval
|
|
||||||
if let timestamp {
|
|
||||||
resolvedTimestamp = timestamp
|
|
||||||
} else {
|
|
||||||
resolvedTimestamp = nextTailTimestamp
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
}
|
|
||||||
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
|
||||||
return makeMessage(
|
|
||||||
id: id,
|
|
||||||
timestamp: resolvedTimestamp,
|
|
||||||
content: "\(tag) \(number)\(dropMarker)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
|
||||||
let expected = reference.append(message)
|
|
||||||
let actual = store.append(message, to: .mesh)
|
|
||||||
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
|
||||||
trimmedCount += expected.trimmedCount
|
|
||||||
return expected
|
|
||||||
}
|
|
||||||
|
|
||||||
func refill(extra: Int, checkpoint: String) async {
|
|
||||||
let appendCount = max(0, cap - reference.messages.count) + extra
|
|
||||||
for index in 0..<appendCount {
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(tag: "refill"),
|
|
||||||
checkpoint: "\(checkpoint)-\(index)"
|
|
||||||
)
|
|
||||||
if index.isMultiple(of: 64) {
|
|
||||||
await Task.yield()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start well into steady state so the offset is already non-zero
|
|
||||||
// before any mixed operations begin.
|
|
||||||
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
|
||||||
|
|
||||||
for step in 0..<1_200 {
|
|
||||||
if step == 300 || step == 900 {
|
|
||||||
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
|
||||||
reference.removeAll { $0.content.contains("[drop]") }
|
|
||||||
filterCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "filter at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if step == 600 {
|
|
||||||
store.clear(.mesh)
|
|
||||||
reference.clear()
|
|
||||||
clearCount += 1
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "clear at step \(step)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch rng.index(upperBound: 100) {
|
|
||||||
case 0..<35:
|
|
||||||
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
|
||||||
tailAppendCount += 1
|
|
||||||
|
|
||||||
case 35..<55:
|
|
||||||
if reference.messages.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
|
||||||
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
|
||||||
appendAndCompare(
|
|
||||||
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
|
||||||
checkpoint: "out-of-order append \(step)"
|
|
||||||
)
|
|
||||||
outOfOrderCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 55..<65:
|
|
||||||
if issuedIDs.isEmpty {
|
|
||||||
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
|
||||||
} else {
|
|
||||||
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
|
||||||
let message = makeMessage(
|
|
||||||
id: reusedID,
|
|
||||||
timestamp: nextTailTimestamp,
|
|
||||||
content: "duplicate-or-trimmed-reuse \(step)"
|
|
||||||
)
|
|
||||||
nextTailTimestamp += 1
|
|
||||||
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
|
||||||
duplicateOrReuseCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 65..<73:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let expected = reference.remove(at: 0)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "head removal mismatch at step \(step)")
|
|
||||||
headRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 73..<81:
|
|
||||||
if !reference.messages.isEmpty {
|
|
||||||
let middleStart = reference.messages.count / 4
|
|
||||||
let middleWidth = max(1, reference.messages.count / 2)
|
|
||||||
let index = min(
|
|
||||||
reference.messages.count - 1,
|
|
||||||
middleStart + rng.index(upperBound: middleWidth)
|
|
||||||
)
|
|
||||||
let expected = reference.remove(at: index)
|
|
||||||
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
|
||||||
.map(ReferenceConversationTimeline.Message.init)
|
|
||||||
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
|
||||||
middleRemovalCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
case 81..<90:
|
|
||||||
let message: BitchatMessage
|
|
||||||
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
|
||||||
let timestamp = reference.messages.isEmpty
|
|
||||||
? nil
|
|
||||||
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
.timestamp.timeIntervalSince1970
|
|
||||||
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
message = makeMessage(
|
|
||||||
id: current.id,
|
|
||||||
timestamp: current.timestamp.timeIntervalSince1970,
|
|
||||||
content: "upsert-existing \(step)",
|
|
||||||
deliveryStatus: current.deliveryStatus
|
|
||||||
)
|
|
||||||
}
|
|
||||||
trimmedCount += reference.upsert(message)
|
|
||||||
store.upsertByID(message, in: .mesh)
|
|
||||||
upsertCount += 1
|
|
||||||
|
|
||||||
default:
|
|
||||||
let id: String
|
|
||||||
let repeatedStatus: DeliveryStatus?
|
|
||||||
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
|
||||||
id = "missing-\(step)"
|
|
||||||
repeatedStatus = nil
|
|
||||||
} else {
|
|
||||||
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
|
||||||
id = current.id
|
|
||||||
repeatedStatus = current.deliveryStatus
|
|
||||||
}
|
|
||||||
let status: DeliveryStatus
|
|
||||||
if step.isMultiple(of: 4), let repeatedStatus {
|
|
||||||
status = repeatedStatus
|
|
||||||
} else {
|
|
||||||
status = .delivered(
|
|
||||||
to: "peer",
|
|
||||||
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let expected = reference.applyDeliveryStatus(status, to: id)
|
|
||||||
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
|
||||||
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
|
||||||
deliveryUpdateCount += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
expectStore(
|
|
||||||
store,
|
|
||||||
matches: reference,
|
|
||||||
issuedIDs: issuedIDs,
|
|
||||||
checkpoint: "mixed operation \(step)"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This intentionally expensive MainActor stress test runs beside
|
|
||||||
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
|
||||||
// release the actor so their bounded waits can make progress.
|
|
||||||
await Task.yield()
|
|
||||||
|
|
||||||
if (step + 1).isMultiple(of: 100) {
|
|
||||||
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guarantee another long run of one-row evictions after every other
|
|
||||||
// mutation family has perturbed and rebuilt the offset/index state.
|
|
||||||
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
|
||||||
|
|
||||||
#expect(trimmedCount > 1_200)
|
|
||||||
#expect(tailAppendCount > 300)
|
|
||||||
#expect(outOfOrderCount > 150)
|
|
||||||
#expect(duplicateOrReuseCount > 75)
|
|
||||||
#expect(headRemovalCount > 50)
|
|
||||||
#expect(middleRemovalCount > 50)
|
|
||||||
#expect(upsertCount > 75)
|
|
||||||
#expect(deliveryUpdateCount > 75)
|
|
||||||
#expect(filterCount == 2)
|
|
||||||
#expect(clearCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
|
|||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
#expect(content.hasVeryLongToken(threshold: 50))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
|
||||||
|
|
||||||
|
#expect(content.extractCashuLinks().count == 1)
|
||||||
|
#expect(content.isLongForDisplay())
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
|
||||||
|
let context = MockMessageFormattingContext(nickname: "carol")
|
||||||
|
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
||||||
|
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "long-cashu",
|
||||||
|
sender: "alice",
|
||||||
|
content: longContent,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
|
||||||
|
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
||||||
|
|
||||||
|
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
|
|||||||
#expect(plain)
|
#expect(plain)
|
||||||
#expect(!plainExhausted)
|
#expect(!plainExhausted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Content buckets do not grow when sender is rate limited")
|
||||||
|
func contentBucketsDoNotGrowAfterSenderLimit() {
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 1,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: 10,
|
||||||
|
maxContentBuckets: 10,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
|
||||||
|
var rejected = true
|
||||||
|
for index in 1...100 {
|
||||||
|
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
|
||||||
|
rejected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(first)
|
||||||
|
#expect(rejected)
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == 1)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Bucket maps evict entries at configured caps")
|
||||||
|
func bucketMapsEvictAtConfiguredCaps() {
|
||||||
|
let maxEntries = 3
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 1,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: maxEntries,
|
||||||
|
maxContentBuckets: maxEntries,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
for index in 0..<25 {
|
||||||
|
_ = limiter.allow(
|
||||||
|
senderKey: "sender-\(index)",
|
||||||
|
contentKey: "content-\(index)",
|
||||||
|
now: now.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("PoW bypass still creates content buckets under the cap")
|
||||||
|
func powBypassCreatesBoundedContentBuckets() {
|
||||||
|
let maxEntries = 3
|
||||||
|
var limiter = MessageRateLimiter(
|
||||||
|
senderCapacity: 1,
|
||||||
|
senderRefillPerSec: 0,
|
||||||
|
contentCapacity: 100,
|
||||||
|
contentRefillPerSec: 0,
|
||||||
|
maxSenderBuckets: maxEntries,
|
||||||
|
maxContentBuckets: maxEntries,
|
||||||
|
bucketIdleTTL: 60
|
||||||
|
)
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
var allAllowed = true
|
||||||
|
for index in 0..<10 {
|
||||||
|
let allowed = limiter.allow(
|
||||||
|
senderKey: "sender",
|
||||||
|
contentKey: "content-\(index)",
|
||||||
|
powBits: NostrPoW.rateLimitBypassBits,
|
||||||
|
now: now.addingTimeInterval(TimeInterval(index))
|
||||||
|
)
|
||||||
|
if !allowed { allAllowed = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(allAllowed)
|
||||||
|
#expect(limiter.bucketCountsForTesting.sender == 0)
|
||||||
|
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ struct NoiseCoverageTests {
|
|||||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
// historical names, but derive each wire ID from the static key that the
|
||||||
|
// corresponding manager authenticates during the handshake.
|
||||||
|
private var alicePeerID: PeerID {
|
||||||
|
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
|
private var bobPeerID: PeerID {
|
||||||
|
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||||
|
|
||||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||||
|
|||||||
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
|
|||||||
#expect(object["limit"] as? Int == 42)
|
#expect(object["limit"] as? Int == 42)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = Array(
|
||||||
|
repeating: ["g", "u4pruyd"],
|
||||||
|
count: TransportConfig.nostrMaxEventTags + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [Array(
|
||||||
|
repeating: "value",
|
||||||
|
count: TransportConfig.nostrMaxEventTagValues + 1
|
||||||
|
)]
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [[
|
||||||
|
"g",
|
||||||
|
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
|
||||||
|
]]
|
||||||
|
|
||||||
|
#expect(throws: NostrError.invalidEvent) {
|
||||||
|
_ = try NostrEvent(from: eventDict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
|
||||||
|
var eventDict = Self.validInboundEventDict()
|
||||||
|
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
|
||||||
|
|
||||||
|
let event = try NostrEvent(from: eventDict)
|
||||||
|
|
||||||
|
#expect(event.tags.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
private static func validInboundEventDict() -> [String: Any] {
|
||||||
|
[
|
||||||
|
"id": String(repeating: "0", count: 64),
|
||||||
|
"pubkey": String(repeating: "1", count: 64),
|
||||||
|
"created_at": 1_234_567,
|
||||||
|
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||||
|
"tags": [["g", "u4pruyd"]],
|
||||||
|
"content": "hello",
|
||||||
|
"sig": String(repeating: "2", count: 128)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
let rem = str.count % 4
|
let rem = str.count % 4
|
||||||
|
|||||||
@@ -501,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 7b. ConversationStore append at the retention cap
|
|
||||||
|
|
||||||
/// Steady-state public timeline traffic after the 1337-message retention
|
|
||||||
/// cap has been reached. Every tail append evicts the oldest row, which is
|
|
||||||
/// the long-lived workload the cold `store.append` benchmark does not
|
|
||||||
/// exercise.
|
|
||||||
func testConversationStoreSteadyStateAppend() {
|
|
||||||
let store = ConversationStore()
|
|
||||||
let cap = TransportConfig.meshTimelineCap
|
|
||||||
let messagesPerPass = 500
|
|
||||||
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
|
|
||||||
for i in 0..<cap {
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-seed-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state seed \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pass = 0
|
|
||||||
var samples: [TimeInterval] = []
|
|
||||||
measure {
|
|
||||||
let startIndex = cap + pass * messagesPerPass
|
|
||||||
let start = Date()
|
|
||||||
for offset in 0..<messagesPerPass {
|
|
||||||
let i = startIndex + offset
|
|
||||||
store.append(
|
|
||||||
BitchatMessage(
|
|
||||||
id: "perf-steady-\(i)",
|
|
||||||
sender: "perfsender",
|
|
||||||
content: "steady-state message \(i)",
|
|
||||||
timestamp: base.addingTimeInterval(Double(i)),
|
|
||||||
isRelay: false
|
|
||||||
),
|
|
||||||
to: .mesh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
samples.append(Date().timeIntervalSince(start))
|
|
||||||
pass += 1
|
|
||||||
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
|
||||||
}
|
|
||||||
|
|
||||||
reportThroughput(
|
|
||||||
"store.steadyStateAppend",
|
|
||||||
samples: samples,
|
|
||||||
operations: messagesPerPass,
|
|
||||||
unit: "messages"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,10 +30,6 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
"_reference_local_numbers_2026_07": {
|
|
||||||
"store.steadyStateAppend_before": 2315,
|
|
||||||
"store.steadyStateAppend": 53976
|
|
||||||
},
|
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -45,7 +41,6 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
"store.steadyStateAppend": 10000,
|
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
|
|||||||
@@ -152,6 +152,21 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
|
||||||
|
recorder.hasSession = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||||
|
|
||||||
|
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||||
|
|
||||||
|
#expect(recorder.hasSessionQueries.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
#expect(recorder.broadcastPackets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Encrypted
|
// MARK: Encrypted
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -91,39 +91,150 @@ struct NoiseEncryptionServiceTests {
|
|||||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let alicePeerID = PeerID(str: "0011223344556677")
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
let recorder = AuthenticationRecorder()
|
let recorder = AuthenticationRecorder()
|
||||||
|
|
||||||
#expect(alice.onPeerAuthenticated == nil)
|
#expect(alice.onPeerAuthenticated == nil)
|
||||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||||
|
|
||||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||||
#expect(authenticated)
|
#expect(authenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
#expect(alice.hasSession(with: alicePeerID))
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
#expect(bob.hasSession(with: bobPeerID))
|
#expect(bob.hasSession(with: alicePeerID))
|
||||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||||
|
|
||||||
let plaintext = Data("secret payload".utf8)
|
let plaintext = Data("secret payload".utf8)
|
||||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
#expect(decrypted == plaintext)
|
#expect(decrypted == plaintext)
|
||||||
|
|
||||||
alice.clearSession(for: alicePeerID)
|
alice.clearSession(for: bobPeerID)
|
||||||
#expect(!alice.hasSession(with: alicePeerID))
|
#expect(!alice.hasSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||||
|
|
||||||
bob.clearEphemeralStateForPanic()
|
bob.clearEphemeralStateForPanic()
|
||||||
#expect(!bob.hasSession(with: bobPeerID))
|
#expect(!bob.hasSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
||||||
|
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
||||||
|
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected mismatch error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||||
|
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Failed forged replacement preserves the established peer session")
|
||||||
|
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
let initialAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(initialAuthentication)
|
||||||
|
|
||||||
|
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
||||||
|
|
||||||
|
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let forgedMessage2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||||
|
)
|
||||||
|
// The replacement has not authenticated yet; the working Alice
|
||||||
|
// transport session must remain available throughout the candidate.
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let forgedMessage3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||||
|
Issue.record("Expected forged replacement to fail peer binding")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected replacement error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||||
|
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 1 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedReplacementAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Valid rehandshake atomically replaces the established session")
|
||||||
|
func validRehandshakeReplacesEstablishedSession() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
alice.clearSession(for: receiverPeerID)
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||||
@@ -200,16 +311,16 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
private func establishSessions(
|
private func establishSessions(
|
||||||
alice: NoiseEncryptionService,
|
alice: NoiseEncryptionService,
|
||||||
bob: NoiseEncryptionService,
|
bob: NoiseEncryptionService
|
||||||
alicePeerID: PeerID,
|
|
||||||
bobPeerID: PeerID
|
|
||||||
) throws {
|
) throws {
|
||||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
let message2 = try #require(response, "Expected handshake response")
|
let message2 = try #require(response, "Expected handshake response")
|
||||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
let message3 = try #require(final, "Expected handshake final")
|
let message3 = try #require(final, "Expected handshake final")
|
||||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
#expect(finalMessage == nil)
|
#expect(finalMessage == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user