Compare commits

..
Author SHA1 Message Date
jackandjack ae3339a1a5 Bind Noise sessions to claimed peer identities 2026-07-25 16:54:50 +02:00
ca18843bb0 Add Persian (fa) localization and an in-app language picker (#1443)
* Add Persian (fa) localization and an in-app language picker

Persian was the one notable gap in the 29-language catalog. Translate all
381 strings (plus the share extension) with proper plural substitutions,
and register fa in knownRegions.

Settings gains a LANGUAGE section: a picker over every language the bundle
ships (native names via Locale), backed by an AppleLanguages override so
users can run bitchat in a language different from the device's. Localization
resolves at process start, so the picker surfaces a restart note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove unused AppLanguageSettings.currentOverride (Periphery)

AppInfoView reads the override via @AppStorage, so the accessor was dead
code and failed the Periphery CI scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-07-25 15:42:17 +02:00
593fd7d737 Harden public intake bounds against untrusted growth (#1451)
* Bound public rate-limit buckets against attacker-keyed growth.

Keep the NIP-13 PoW sender bypass, skip content-bucket minting on
sender reject, and evict idle/oldest entries at a hard cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop Cashu-looking text from skipping long-message guards.

Oversized public content always collapses and takes the plain
formatting path so remote tokens cannot force layout/regex DoS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap teleported geohash participant markers.

Bound the set with FIFO eviction, clear it on channel switch, and
prune markers that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound untrusted Nostr relay frames and event tags.

Reject oversized inbound messages before JSON parse, cap tag
arrays/values at decode, and stop logging raw tag contents.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fail soft when Noise handshake state is unexpectedly missing.

Replace the initiator startHandshake force unwrap with a guard
that throws invalidState instead of crashing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cap geohash nickname cache from remote Nostr events.

FIFO-evict at capacity, clear on channel switch, and prune
nicknames that leave the visible participant list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Avoid overlapping exclusive access in the rate limiter.

Make bucket helpers static so inout dictionary updates do not
conflict with a mutating call on self.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix rate-limiter tests for mutating allow under #expect.

Call allow outside the macro so Swift Testing does not capture an
immutable copy of the struct.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop unused WebSocket data helper; reset rate limiter on panic wipe.

dataWithinInboundLimit replaced the unbounded path, and panic clear
should not leave public intake buckets behind.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:25:46 +02:00
36 changed files with 4215 additions and 628 deletions
+1
View File
@@ -337,6 +337,7 @@
es, es,
ar, ar,
de, de,
fa,
fr, fr,
he, he,
id, id,
+25 -41
View File
@@ -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
+111 -10
View File
@@ -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()
} }
} }
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
+4 -1
View File
@@ -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 {
+1
View File
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case alreadyEstablished case alreadyEstablished
case peerIdentityMismatch
} }
+97 -32
View File
@@ -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 {
+19
View File
@@ -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()
+13 -5
View File
@@ -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) {
+56 -4
View File
@@ -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)
} }
+12
View File
@@ -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
+40
View File
@@ -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
+1
View File
@@ -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() {
+79 -8
View File
@@ -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) {
+73
View File
@@ -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) {
+20
View File
@@ -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",
+38
View File
@@ -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"))
+101
View File
@@ -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] = []
+19
View File
@@ -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()
-424
View File
@@ -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)
}
} }
+9 -2
View File
@@ -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")
+58
View File
@@ -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)
} }
} }