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
32 changed files with 4189 additions and 101 deletions
+1
View File
@@ -337,6 +337,7 @@
es,
ar,
de,
fa,
fr,
he,
id,
+111 -10
View File
@@ -7,45 +7,146 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: 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?) {
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) {
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]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
guard geoNicknameCapacity > 0 else {
clearGeoNicknames()
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() {
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) {
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) {
teleportedGeo.remove(pubkeyHex.lowercased())
let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
}
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() {
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
geoNicknameOrder.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
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
static let sessionTimeout: TimeInterval = 86400 // 24 hours
+4 -1
View File
@@ -66,7 +66,10 @@ class NoiseSession {
// Only initiator writes the first message
if role == .initiator {
let message = try handshakeState!.writeMessage()
guard let handshake = handshakeState else {
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message)
return message
} else {
+1
View File
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished
case sessionNotFound
case alreadyEstablished
case peerIdentityMismatch
}
+97 -32
View File
@@ -13,6 +13,11 @@ import BitFoundation
final class NoiseSessionManager {
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 managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
@@ -54,6 +59,9 @@ final class NoiseSessionManager {
if let session = sessions.removeValue(forKey: peerID) {
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 {
session.reset()
}
for (_, candidate) in responderCandidates {
candidate.reset()
}
sessions.removeAll()
responderCandidates.removeAll()
}
}
@@ -79,6 +91,7 @@ final class NoiseSessionManager {
// Remove any existing non-established session
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
_ = sessions.removeValue(forKey: peerID)
existingSession.reset()
}
// Create new initiator session
@@ -91,6 +104,7 @@ final class NoiseSessionManager {
} catch {
// Clean up failed session
_ = sessions.removeValue(forKey: peerID)
session.reset()
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
throw error
}
@@ -100,39 +114,50 @@ final class NoiseSessionManager {
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
// Process everything within the synchronized block to prevent race conditions
return try managerQueue.sync(flags: .barrier) {
var shouldCreateNew = false
var existingSession: NoiseSession? = nil
let session: NoiseSession
let isReplacementCandidate: Bool
if let existing = sessions[peerID] {
// If we have an established session, the peer must have cleared their session
// for a good reason (e.g., decryption failure, restart, etc.)
// We should accept the new handshake to re-establish encryption
if existing.isEstablished() {
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
if let candidate = responderCandidates[peerID] {
// A fresh XX message 1 supersedes an incomplete candidate,
// but never the established session it is trying to replace.
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
candidate.reset()
let replacement = sessionFactory(peerID, .responder)
responderCandidates[peerID] = replacement
session = replacement
} else {
// If we're in the middle of a handshake and receive a new initiation,
// reset and start fresh (the other side may have restarted)
if existing.getState() == .handshaking && message.count == 32 {
_ = sessions.removeValue(forKey: peerID)
shouldCreateNew = true
} else {
existingSession = existing
}
session = candidate
}
isReplacementCandidate = true
} else if let existing = sessions[peerID] {
if existing.isEstablished() {
SecureLogger.info(
"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 {
shouldCreateNew = true
}
// Get or create session
let session: NoiseSession
if shouldCreateNew {
let newSession = sessionFactory(peerID, .responder)
sessions[peerID] = newSession
session = newSession
} else {
session = existingSession!
isReplacementCandidate = false
}
// Process the handshake message within the synchronized block
@@ -141,18 +166,40 @@ final class NoiseSessionManager {
// Check if session is established after processing
if session.isEstablished() {
if let remoteKey = session.getRemoteStaticPublicKey() {
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
self?.onSessionEstablished?(peerID, remoteKey)
guard let remoteKey = session.getRemoteStaticPublicKey(),
authenticatedRemoteKey(remoteKey, matches: peerID) else {
throw NoiseSessionError.peerIdentityMismatch
}
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
} catch {
// Reset the session on handshake failure so next attempt can start fresh
_ = sessions.removeValue(forKey: peerID)
// A failed candidate is discarded without touching the
// 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
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
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
+19
View File
@@ -701,6 +701,10 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent
}
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey
self.created_at = createdAt
@@ -710,6 +714,21 @@ struct NostrEvent: Codable {
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 {
let (eventId, eventIdHash) = try calculateEventId()
+13 -5
View File
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.data,
guard let data = message.dataWithinInboundLimit,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2,
let type = array[0] as? String else {
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
}
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 {
case .string(let text): text.data(using: .utf8)
case .data(let data): data
@unknown default: nil
case .string(let text):
guard text.utf8.count <= maxBytes else { return 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
}
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
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
@@ -72,14 +76,26 @@ final class BLENoisePacketHandler {
// Session establishment will trigger onPeerAuthenticated callback
// 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 {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
return false
}
}
return false
}
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) {
// Remove the peer when they leave
peerRegistry.remove(peerID)
@@ -1635,6 +1672,7 @@ final class BLEService: NSObject {
self.deliverTransportEvent(.peerDisconnected(peerID))
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
return true
}
private func sendAnnounce(forceSend: Bool = false) {
// 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) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
@@ -4785,7 +4829,9 @@ extension BLEService {
handleMeshPong(packet, from: senderID)
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:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
@@ -5426,8 +5472,14 @@ extension BLEService {
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
noisePacketHandler.handleHandshake(packet, from: peerID)
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
let processed = noisePacketHandler.handleHandshake(packet, from: 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)
}
}
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
isSelf: Bool,
isMentioned: Bool
) -> AttributedString {
// For very long content without special tokens, use plain formatting
let containsCashu = containsCashuToken(content)
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
// For very long content, use plain formatting to avoid expensive
// regex/detector work. Cashu presence must not disable this guard.
if content.isOversizedForRichFormatting() {
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
}
+12
View File
@@ -46,6 +46,7 @@ enum TransportConfig {
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let geoNicknameParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
@@ -81,6 +82,11 @@ enum TransportConfig {
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
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)
// Sample interval for the periodic store-audit "OK" heartbeat line
@@ -98,6 +104,12 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
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
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 nsContent = content as NSString
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()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
+1
View File
@@ -1183,6 +1183,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
identityManager.clearAllIdentityData()
peerIdentityStore.clearAll()
locationPresenceStore.reset()
publicRateLimiter.reset()
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
@@ -156,6 +156,17 @@ private extension ChatViewModelBootstrapper {
viewModel?.objectWillChange.send()
}
.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() {
+79 -8
View File
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
}
return false
}
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
}
private var senderBuckets: [String: TokenBucket] = [:]
@@ -35,17 +39,26 @@ struct MessageRateLimiter {
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double
contentRefillPerSec: Double,
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
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
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
if powBits >= NostrPoW.rateLimitBypassBits {
senderAllowed = true
} else {
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
var senderBucket = Self.bucket(
for: senderKey,
in: &senderBuckets,
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
lastRefill: now
maxBuckets: maxSenderBuckets,
idleTTL: bucketIdleTTL,
now: now
)
senderAllowed = senderBucket.allow(now: now)
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,
tokens: contentCapacity,
refillPerSec: contentRefill,
lastRefill: now
maxBuckets: maxContentBuckets,
idleTTL: bucketIdleTTL,
now: now
)
let contentAllowed = contentBucket.allow(now: now)
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.
geoEventLogCount += 1
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) {
+73
View File
@@ -26,6 +26,10 @@ struct AppInfoView: View {
/// introduction), and afterwards the sheet reopens wherever it was left.
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
@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 {
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 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 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 {
@@ -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.
VStack(alignment: .leading, spacing: 12) {
SectionHeader(Strings.Voice.title)
@@ -458,6 +513,24 @@ struct AppInfoView: View {
.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> {
Binding(
get: { bridgeService.isEnabled },
@@ -41,7 +41,7 @@ struct TextMessageView: View {
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
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)
if message.isPrivate {
Image(systemName: "lock.fill")
@@ -103,7 +103,7 @@ struct TextMessageView: View {
}
// 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 labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) {
+20
View File
@@ -21,6 +21,26 @@ extension String {
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
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
// form matches too the token embedded after the scheme is the match.
@@ -38,6 +38,12 @@
"comment" : "Fallback title when saving a shared link"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیوند اشتراک‌گذاری‌شده"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -233,6 +239,12 @@
"comment" : "Shown when the share payload cannot be encoded"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "کدگذاری پیوند ناموفق بود"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -428,6 +440,12 @@
"comment" : "Shown when provided content cannot be shared"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "محتوای قابل اشتراک‌گذاری وجود ندارد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -623,6 +641,12 @@
"comment" : "Shown when the share extension receives no content"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "چیزی برای اشتراک‌گذاری نیست"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -818,6 +842,12 @@
"comment" : "Confirmation after successfully sharing a link"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
@@ -1013,6 +1043,12 @@
"comment" : "Confirmation after successfully sharing text"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
+38
View File
@@ -147,6 +147,44 @@ struct AppArchitectureTests {
#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")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
+101
View File
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
#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
func ingressAllowsRelayedSenderOnBoundLink() async throws {
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 let lock = NSLock()
private(set) var publicMessages: [BitchatMessage] = []
+19
View File
@@ -646,6 +646,25 @@ struct ChatViewModelFormattingTests {
#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
func formatMessageHeader_formatsSenderHeader() async {
let (viewModel, _) = makeTestableViewModel()
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
// Exactly at threshold DOES trigger (uses >= comparison)
#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
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
#expect(plain)
#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 charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let alicePeerID = PeerID(str: "0011223344556677")
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
// Manager test dictionaries are keyed by the remote peer. Keep the
// 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")
@Test("Protocol metadata and handshake patterns expose expected values")
+58
View File
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
#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
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? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
@@ -152,6 +152,21 @@ struct BLENoisePacketHandlerTests {
#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
@Test
@@ -91,39 +91,150 @@ struct NoiseEncryptionServiceTests {
func handshakeEncryptionAndFingerprintLifecycle() async throws {
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bob = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(str: "0011223344556677")
let bobPeerID = PeerID(str: "8899aabbccddeeff")
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
let recorder = AuthenticationRecorder()
#expect(alice.onPeerAuthenticated == nil)
alice.addOnPeerAuthenticatedHandler(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)
#expect(authenticated)
#expect(alice.hasEstablishedSession(with: alicePeerID))
#expect(bob.hasEstablishedSession(with: bobPeerID))
#expect(alice.hasSession(with: alicePeerID))
#expect(bob.hasSession(with: bobPeerID))
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
#expect(alice.hasEstablishedSession(with: bobPeerID))
#expect(bob.hasEstablishedSession(with: alicePeerID))
#expect(alice.hasSession(with: bobPeerID))
#expect(bob.hasSession(with: alicePeerID))
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
let plaintext = Data("secret payload".utf8)
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
#expect(decrypted == plaintext)
alice.clearSession(for: alicePeerID)
#expect(!alice.hasSession(with: alicePeerID))
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
alice.clearSession(for: bobPeerID)
#expect(!alice.hasSession(with: bobPeerID))
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
bob.clearEphemeralStateForPanic()
#expect(!bob.hasSession(with: bobPeerID))
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
#expect(!bob.hasSession(with: alicePeerID))
#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")
@@ -200,16 +311,16 @@ struct NoiseEncryptionServiceTests {
private func establishSessions(
alice: NoiseEncryptionService,
bob: NoiseEncryptionService,
alicePeerID: PeerID,
bobPeerID: PeerID
bob: NoiseEncryptionService
) throws {
let message1 = try alice.initiateHandshake(with: alicePeerID)
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
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 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 finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
#expect(finalMessage == nil)
}
}