+ /// messageID → prekey ID, so re-deposits of one message share one prekey.
+ var assignments: [String: UInt32]
+ var updatedAt: Date
+ }
+
+ enum Limits {
+ static let maxPeers = 200
+ /// Don't seal to bundles older than this: the owner may have rotated
+ /// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
+ static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
+ }
+
+ static let shared = PrekeyBundleStore()
+
+ private var bundles: [Data: StoredBundle] = [:]
+ private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
+ private let fileURL: URL?
+ private let maxPeers: Int
+ private let now: () -> Date
+
+ /// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
+ /// when `persistsToDisk` is false.
+ init(
+ persistsToDisk: Bool = true,
+ fileURL: URL? = nil,
+ maxPeers: Int = Limits.maxPeers,
+ now: @escaping () -> Date = Date.init
+ ) {
+ self.now = now
+ self.maxPeers = maxPeers
+ self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
+ loadFromDisk()
+ }
+
+ // MARK: - Ingest
+
+ /// Stores a bundle whose signature the caller has already verified
+ /// against the owner's announce-bound signing key. Returns false when an
+ /// equal-or-newer bundle is already cached (nothing changed).
+ @discardableResult
+ func ingest(_ bundle: PrekeyBundle) -> Bool {
+ guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
+ !bundle.prekeys.isEmpty else { return false }
+ return queue.sync {
+ if let existing = bundles[bundle.noiseStaticPublicKey],
+ existing.generatedAt >= bundle.generatedAt {
+ return false
+ }
+ let previous = bundles[bundle.noiseStaticPublicKey]
+ let newIDs = Set(bundle.prekeys.map(\.id))
+ // Keep consumption state for IDs the fresh bundle still offers
+ // (a top-up keeps the owner's unconsumed keys); drop the rest.
+ let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
+ let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
+ bundles[bundle.noiseStaticPublicKey] = StoredBundle(
+ noiseKey: bundle.noiseStaticPublicKey,
+ generatedAt: bundle.generatedAt,
+ prekeyIDs: bundle.prekeys.map(\.id),
+ prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
+ usedIDs: carriedUsed,
+ assignments: carriedAssignments,
+ updatedAt: now()
+ )
+ enforceCapLocked()
+ persistLocked()
+ return true
+ }
+ }
+
+ // MARK: - Sealing support
+
+ /// Whether an unexpired bundle with sealable prekeys is cached for a peer.
+ func hasUsableBundle(for noiseKey: Data) -> Bool {
+ queue.sync {
+ guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
+ return bundle.usedIDs.count < bundle.prekeyIDs.count
+ }
+ }
+
+ /// The prekey to seal a message with: the message's existing assignment if
+ /// any (re-deposits reuse it), else the lowest unused ID, which is then
+ /// marked used. Nil when no fresh bundle is cached or all its prekeys are
+ /// spent — callers fall back to static sealing.
+ func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
+ queue.sync {
+ guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
+
+ if let assigned = bundle.assignments[messageID],
+ let index = bundle.prekeyIDs.firstIndex(of: assigned) {
+ return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
+ }
+
+ guard let index = bundle.prekeyIDs.indices
+ .filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
+ .min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
+ return nil
+ }
+ let id = bundle.prekeyIDs[index]
+ bundle.usedIDs.insert(id)
+ bundle.assignments[messageID] = id
+ bundle.updatedAt = now()
+ bundles[recipientNoiseKey] = bundle
+ persistLocked()
+ return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
+ }
+ }
+
+ // MARK: - Maintenance
+
+ /// Panic wipe: drop all cached bundles from memory and disk.
+ func wipe() {
+ queue.sync {
+ bundles.removeAll()
+ if let fileURL {
+ try? FileManager.default.removeItem(at: fileURL)
+ }
+ }
+ }
+
+ // MARK: - Internals (call only on `queue`)
+
+ private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
+ let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
+ return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
+ }
+
+ private func enforceCapLocked() {
+ while bundles.count > maxPeers {
+ guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
+ bundles.removeValue(forKey: victim.key)
+ }
+ }
+
+ private func persistLocked() {
+ guard let fileURL else { return }
+ do {
+ if bundles.isEmpty {
+ try? FileManager.default.removeItem(at: fileURL)
+ return
+ }
+ try FileManager.default.createDirectory(
+ at: fileURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let data = try JSONEncoder().encode(Array(bundles.values))
+ var options: Data.WritingOptions = [.atomic]
+ #if os(iOS)
+ options.insert(.completeFileProtection)
+ #endif
+ try data.write(to: fileURL, options: options)
+ } catch {
+ SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
+ }
+ }
+
+ private func loadFromDisk() {
+ guard let fileURL else { return }
+ queue.sync {
+ guard let data = try? Data(contentsOf: fileURL),
+ let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
+ return
+ }
+ for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
+ bundles[bundle.noiseKey] = bundle
+ }
+ }
+ }
+
+ private static func defaultFileURL() -> URL? {
+ guard let base = try? FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ ) else { return nil }
+ return base
+ .appendingPathComponent("prekeys", isDirectory: true)
+ .appendingPathComponent("bundles.json")
+ }
+}
diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift
index 37fa8584..10b4f2c6 100644
--- a/bitchat/Services/RelayController.swift
+++ b/bitchat/Services/RelayController.swift
@@ -18,10 +18,18 @@ struct RelayController {
isDirectedFragment: Bool,
isHandshake: Bool,
isAnnounce: Bool,
+ isRequestSync: Bool = false,
+ isUrgentBoardPost: Bool = false,
degree: Int,
highDegreeThreshold: Int) -> RelayDecision {
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
+ // REQUEST_SYNC is link-local: never relay it, even when a peer crafts
+ // one with TTL headroom to turn every reachable node into a responder.
+ if isRequestSync {
+ return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
+ }
+
// Suppress obvious non-relays
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
@@ -57,7 +65,7 @@ struct RelayController {
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Thin chains (degree <= 2): every hop counts and flood cost is
// minimal, so relay at full incoming depth
- // - Announces get a bit more headroom
+ // - Announces (and urgent board posts) get a bit more headroom
let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5)))
@@ -65,7 +73,7 @@ struct RelayController {
if degree <= 2 {
return ttlCap
}
- let preferred = UInt8(isAnnounce ? 7 : 6)
+ let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
}()
let newTTL = ttlLimit &- 1
diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift
index 5cd095a6..d6bdef33 100644
--- a/bitchat/Services/Transport.swift
+++ b/bitchat/Services/Transport.swift
@@ -11,12 +11,67 @@ struct TransportPeerSnapshot: Equatable, Hashable {
let isConnected: Bool
let noisePublicKey: Data?
let lastSeen: Date
+ /// Whether the peer's announce was signature-verified (courier tier gate).
+ let isVerified: Bool
+
+ init(
+ peerID: PeerID,
+ nickname: String,
+ isConnected: Bool,
+ noisePublicKey: Data?,
+ lastSeen: Date,
+ isVerified: Bool = false
+ ) {
+ self.peerID = peerID
+ self.nickname = nickname
+ self.isConnected = isConnected
+ self.noisePublicKey = noisePublicKey
+ self.lastSeen = lastSeen
+ self.isVerified = isVerified
+ }
+}
+
+/// Outcome of a `/ping` probe over the mesh.
+struct MeshPingResult: Equatable {
+ /// Round-trip time in milliseconds.
+ let rttMs: Int
+ /// Total hops to the peer (1 = directly connected), derived from the
+ /// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
+ let hops: Int?
+}
+
+/// Undirected mesh link between two peers, normalized so `(a, b)` and
+/// `(b, a)` collapse to one edge.
+struct MeshTopologyEdge: Hashable {
+ let a: PeerID
+ let b: PeerID
+
+ init(_ first: PeerID, _ second: PeerID) {
+ if first < second {
+ a = first
+ b = second
+ } else {
+ a = second
+ b = first
+ }
+ }
+}
+
+/// Point-in-time view of the mesh graph learned from gossiped announces
+/// (each announce carries up to 10 `directNeighbors`).
+struct MeshTopologySnapshot: Equatable {
+ let localPeerID: PeerID
+ let nodes: [PeerID]
+ let edges: [MeshTopologyEdge]
}
enum TransportEvent: @unchecked Sendable {
case messageReceived(BitchatMessage)
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
+ /// Encrypted group broadcast (MessageType 0x25). Opaque here — the group
+ /// coordinator decrypts and authenticates against the roster.
+ case groupMessageReceived(payload: Data, timestamp: Date)
case peerConnected(PeerID)
case peerDisconnected(PeerID)
case peerListUpdated([PeerID])
@@ -106,10 +161,43 @@ protocol Transport: AnyObject {
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
+ // Private groups (mesh transports only): creator-signed state travels
+ // 1:1 over Noise sessions; group messages flood like public broadcasts.
+ func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
+ func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
+ func broadcastGroupMessage(_ envelope: Data)
+
+ // Bulletin board (mesh transports only): broadcast a pre-signed board
+ // payload (post or tombstone) so it spreads over relay and gossip sync.
+ func sendBoardPayload(_ payload: Data)
+
+ // Mesh diagnostics (optional for transports). Defaults are inert so
+ // queue-backed transports (e.g. NostrTransport) stay untouched.
+ /// Sends a directed ping probe; the completion fires exactly once on the
+ /// main actor with the measured result, or nil on timeout/unsupported.
+ func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
+ /// Estimated intermediate hops toward `peerID` from gossiped topology
+ /// ([] = direct link, nil = no known path).
+ func computeMeshPath(to peerID: PeerID) -> [PeerID]?
+ /// Current mesh graph for the topology map; nil when unsupported.
+ func currentMeshTopology() -> MeshTopologySnapshot?
+
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
+ // Vouching / transitive verification (optional for transports)
+ /// Capabilities the peer advertised in its last verified announce;
+ /// empty for peers that predate the capabilities TLV.
+ func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
+ /// Sends an encoded vouch-attestation batch inside the Noise session.
+ func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
+ /// Appends a peer-authenticated observer. Unlike
+ /// `installNoiseSessionCallbacks` this never touches the (single-slot)
+ /// handshake-required callback, so secondary features can observe
+ /// session establishment without disturbing the primary registration.
+ func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
+
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
@@ -135,7 +223,22 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
+ func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
+ func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
+ func broadcastGroupMessage(_ envelope: Data) {}
+ func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
+ func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
+ func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
+ func sendBoardPayload(_ payload: Data) {}
+
+ // Mesh diagnostics are mesh-transport-only; other transports report
+ // "no reply"/"no path" rather than pretending to measure anything.
+ func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
+ Task { @MainActor in completion(nil) }
+ }
+ func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
+ func currentMeshTopology() -> MeshTopologySnapshot? { nil }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
@@ -168,6 +271,8 @@ extension BitchatDelegate {
)
case let .noisePayloadReceived(peerID, type, payload, timestamp):
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
+ case let .groupMessageReceived(payload, timestamp):
+ didReceiveGroupMessage(payload: payload, timestamp: timestamp)
case .peerConnected(let peerID):
didConnectToPeer(peerID)
case .peerDisconnected(let peerID):
diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift
index 4aae7294..07e11666 100644
--- a/bitchat/Services/TransportConfig.swift
+++ b/bitchat/Services/TransportConfig.swift
@@ -16,6 +16,11 @@ enum TransportConfig {
static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
+ // Mesh diagnostics (/ping)
+ static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
+ static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)...
+ static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
+
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
@@ -208,6 +213,25 @@ enum TransportConfig {
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
+ // Source routing (v2 directed packets)
+ // Longest path we will originate, in intermediate hops between us and the
+ // recipient. Keep small: every hop must be a fresh, confirmed, v2-capable
+ // node, and long stale paths fail more often than floods.
+ static let bleSourceRouteMaxIntermediateHops: Int = 4
+ // A routed send with no inbound traffic from the recipient within this
+ // window counts as a route failure.
+ static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0
+ // After a route failure, directed sends to that recipient flood instead
+ // of routing until this lapses.
+ static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0
+
+ // Targeted fragment resync (REQUEST_SYNC fragmentIdFilter)
+ // A broadcast reassembly with no new fragment for this long is stalled
+ // and triggers a targeted REQUEST_SYNC naming its fragment stream.
+ static let bleFragmentResyncStallSeconds: TimeInterval = 5.0
+ // Minimum spacing between targeted resync requests for the same stream.
+ static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0
+
// Store-and-forward for directed packets at relays. Spooled packets retry
// on each maintenance flush until the window lapses; a longer window lets
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
@@ -262,7 +286,14 @@ enum TransportConfig {
static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01
+ // Fragments and file transfers keep the short window; whole public
+ // messages get hours so a phone walking between partitions carries the
+ // room's recent history with it (see syncPublicMessageMaxAgeSeconds).
static let syncMaxMessageAgeSeconds: TimeInterval = 900
+ // How far back public broadcast messages stay sync-able. Must not exceed
+ // the receive-side acceptance window (BLEPublicMessagePolicy uses this
+ // same constant) or served packets would be dropped as stale.
+ static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
@@ -271,4 +302,27 @@ enum TransportConfig {
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
static let syncMessageIntervalSeconds: TimeInterval = 15.0
+ static let syncResponseRateLimitMaxResponses: Int = 8
+ static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
+
+ // Courier store-and-forward
+ // Initial spray-and-wait budget per deposited envelope: each courier may
+ // hand half its remaining copies to another courier on encounter, so a
+ // message diffuses through a moving crowd instead of riding one person.
+ static let courierInitialCopies: UInt8 = 4
+ // Cooldown between speculative multi-hop handovers of the same envelope
+ // toward a recipient heard only via relayed announces.
+ static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
+
+ // One-time prekey bundles (forward-secret courier sealing)
+ // Own gossip-sync round for bundles: modest cadence, bounded peer count,
+ // and a long freshness window so bundles persist mesh-wide while their
+ // owners are away.
+ static let syncPrekeyBundleCapacity: Int = 200
+ static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0
+ static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
+ // Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on
+ // announces, keep it alive in peers' gossip stores; changed bundles are
+ // sent immediately.
+ static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60
}
diff --git a/bitchat/Sync/GCSFilter.swift b/bitchat/Sync/GCSFilter.swift
index 7d76c23c..8b36b376 100644
--- a/bitchat/Sync/GCSFilter.swift
+++ b/bitchat/Sync/GCSFilter.swift
@@ -10,7 +10,13 @@ import CryptoKit
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1< Params {
let p = deriveP(targetFpr: targetFpr)
guard !ids.isEmpty else {
- return Params(p: p, m: 1, data: Data())
+ return Params(p: p, m: 1, data: Data(), includedCount: 0)
}
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
- let selected = Array(ids.prefix(cap))
- let range = max(1, hashRange(count: selected.count, p: p))
+ // Modulus is fixed to the initial candidate count so `m` stays stable
+ // as the tail is trimmed to fit the byte budget below.
+ let range = max(1, hashRange(count: min(ids.count, cap), p: p))
let modulo = UInt64(range)
- var mapped = selected
- .map { h64($0) }
- .map { mapHash($0, modulo: modulo) }
- .sorted()
- mapped = normalizeMappedValues(mapped, modulo: modulo)
-
- if mapped.isEmpty {
- return Params(p: p, m: range, data: Data())
+ // Encode the first `count` inputs (input order). The caller passes IDs
+ // newest-first, so trimming from the tail drops the oldest — which is
+ // what lets a since-cursor stay exact: the surviving set is always a
+ // contiguous newest-prefix, never a hash-order-arbitrary subset.
+ func encodeFirst(_ count: Int) -> Data {
+ var mapped = ids.prefix(count)
+ .map { h64($0) }
+ .map { mapHash($0, modulo: modulo) }
+ .sorted()
+ mapped = normalizeMappedValues(mapped, modulo: modulo)
+ return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p)
}
- var encoded = encode(sorted: mapped, p: p)
- var trimmedCount = mapped.count
-
- while encoded.count > maxBytes && trimmedCount > 0 {
- if trimmedCount == 1 {
- mapped.removeAll()
- encoded = Data()
- break
- }
- trimmedCount = max(1, (trimmedCount * 9) / 10)
- mapped = Array(mapped.prefix(trimmedCount))
- encoded = encode(sorted: mapped, p: p)
+ var count = min(ids.count, cap)
+ var encoded = encodeFirst(count)
+ while encoded.count > maxBytes && count > 1 {
+ count = max(1, (count * 9) / 10)
+ encoded = encodeFirst(count)
+ }
+ // A single element that still overflows can't be represented.
+ if encoded.count > maxBytes {
+ return Params(p: p, m: range, data: Data(), includedCount: 0)
}
- return Params(p: p, m: range, data: encoded)
+ return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count)
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
diff --git a/bitchat/Sync/GossipMessageArchive.swift b/bitchat/Sync/GossipMessageArchive.swift
new file mode 100644
index 00000000..dd81983d
--- /dev/null
+++ b/bitchat/Sync/GossipMessageArchive.swift
@@ -0,0 +1,80 @@
+//
+// GossipMessageArchive.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitLogger
+import Foundation
+
+/// Disk persistence for the gossip-sync public message store, so the recent
+/// public history a device carries survives app restarts. This is what lets
+/// a phone act as a town crier: walk between two mesh partitions (or relaunch
+/// hours later) and sync the room's backlog to whoever missed it.
+///
+/// Contents are signed public broadcasts — already visible to anyone in radio
+/// range — so file protection (no additional sealing) is the right at-rest
+/// posture. Wiped on panic.
+final class GossipMessageArchive {
+ private let fileURL: URL?
+
+ init(fileURL: URL? = nil) {
+ self.fileURL = fileURL ?? Self.defaultFileURL()
+ }
+
+ /// Raw binary packets, decoded and freshness-filtered by the caller.
+ func load() -> [Data] {
+ guard let fileURL,
+ let data = try? Data(contentsOf: fileURL),
+ let packets = try? JSONDecoder().decode([Data].self, from: data) else {
+ return []
+ }
+ return packets
+ }
+
+ func save(_ packets: [Data]) {
+ guard let fileURL else { return }
+ guard !packets.isEmpty else {
+ try? FileManager.default.removeItem(at: fileURL)
+ return
+ }
+ do {
+ try FileManager.default.createDirectory(
+ at: fileURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let data = try JSONEncoder().encode(packets)
+ var options: Data.WritingOptions = [.atomic]
+ #if os(iOS)
+ options.insert(.completeFileProtection)
+ #endif
+ try data.write(to: fileURL, options: options)
+ } catch {
+ SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync)
+ }
+ }
+
+ func wipe() {
+ guard let fileURL else { return }
+ try? FileManager.default.removeItem(at: fileURL)
+ }
+
+ /// Panic-wipe hook for callers that don't hold the live instance.
+ static func wipeDefault() {
+ GossipMessageArchive().wipe()
+ }
+
+ private static func defaultFileURL() -> URL? {
+ guard let base = try? FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ ) else { return nil }
+ return base
+ .appendingPathComponent("sync", isDirectory: true)
+ .appendingPathComponent("public-messages.json")
+ }
+}
diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift
index 3c8472d2..3dee21e0 100644
--- a/bitchat/Sync/GossipSyncManager.swift
+++ b/bitchat/Sync/GossipSyncManager.swift
@@ -64,41 +64,82 @@ final class GossipSyncManager {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1%
- var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
+ var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
+ // Whole public messages stay sync-able much longer so devices carry
+ // the room's recent history between partitions and across restarts.
+ var publicMessageMaxAgeSeconds: TimeInterval = 900
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
var fragmentCapacity: Int = 600
var fileTransferCapacity: Int = 200
+ var groupMessageCapacity: Int = 200
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
var messageSyncIntervalSeconds: TimeInterval = 15.0
+ // Board posts are few but long-lived (days, until each post's own
+ // expiry), so they get a slow round with their own capacity instead
+ // of competing with the 15-minute message window.
+ var boardCapacity: Int = 200
+ var boardSyncIntervalSeconds: TimeInterval = 60.0
+ var responseRateLimitMaxResponses: Int = 8
+ var responseRateLimitWindowSeconds: TimeInterval = 30.0
+ // Prekey bundles: one per peer, own sync round, long freshness so
+ // bundles persist mesh-wide while their owners are offline.
+ var prekeyBundleCapacity: Int = 200
+ var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0
+ var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
}
private let myPeerID: PeerID
private let config: Config
private let requestSyncManager: RequestSyncManager
+ private let archive: GossipMessageArchive?
weak var delegate: Delegate?
+ /// Source of raw signed board packets (posts + tombstones). The board
+ /// store is the single owner of board retention (expiry, tombstones,
+ /// caps, persistence), so sync rounds query it instead of keeping a
+ /// second copy here. Must be thread-safe; set before `start()`.
+ var boardPacketsProvider: (() -> [BitchatPacket])?
+
// Storage: broadcast packets by type, and latest announce per sender
private var messages = PacketStore()
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
- private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
+ private var groupMessages = PacketStore()
+ private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
+ // Latest verified prekey bundle per owner. Unlike announces, bundles are
+ // NOT dropped on leave/stale peer: their whole purpose is reaching a
+ // sender while the owner is away.
+ private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
+ private var archiveDirty = false
// Timer
private var periodicTimer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
+ private var responseRateLimiter: SyncResponseRateLimiter
- init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
+ init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) {
self.myPeerID = myPeerID
self.config = config
self.requestSyncManager = requestSyncManager
+ self.archive = archive
+ self.responseRateLimiter = SyncResponseRateLimiter(
+ maxResponses: config.responseRateLimitMaxResponses,
+ window: config.responseRateLimitWindowSeconds
+ )
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
- schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
+ // Group messages ride the public-message cadence; old clients
+ // ignore the extended bit and answer with announces/messages only.
+ var messageTypes: SyncTypeFlags = .publicMessages
+ if config.groupMessageCapacity > 0 {
+ messageTypes.formUnion(.groupMessage)
+ }
+ schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
}
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
@@ -106,7 +147,19 @@ final class GossipSyncManager {
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
+ if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
+ schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
+ }
+ if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
+ schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
+ }
syncSchedules = schedules
+
+ if archive != nil {
+ queue.async { [weak self] in
+ self?.restoreArchivedMessages()
+ }
+ }
}
func start() {
@@ -130,12 +183,21 @@ final class GossipSyncManager {
guard let self = self else { return }
var types: SyncTypeFlags = .publicMessages
+ if self.config.groupMessageCapacity > 0 {
+ types.formUnion(.groupMessage)
+ }
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
types.formUnion(.fragment)
}
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
types.formUnion(.fileTransfer)
}
+ if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
+ types.formUnion(.prekeyBundle)
+ }
+ if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
+ types.formUnion(.board)
+ }
self.sendRequestSync(to: peerID, types: types)
}
}
@@ -146,10 +208,23 @@ final class GossipSyncManager {
}
}
- // Helper to check if a packet is within the age threshold
+ // Helper to check if a packet is within the age threshold. Whole public
+ // messages get the long town-crier window; fragments, file transfers and
+ // announces keep the short one.
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
+ // Group messages share the whole-message window: members off the mesh
+ // for a while should backfill their crew's history like public chat.
+ let maxAgeSeconds: TimeInterval
+ switch packet.type {
+ case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
+ maxAgeSeconds = config.publicMessageMaxAgeSeconds
+ case MessageType.prekeyBundle.rawValue:
+ maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
+ default:
+ maxAgeSeconds = config.maxMessageAgeSeconds
+ }
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
- let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
+ let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
// If current time is less than threshold, accept all (handle clock issues gracefully)
guard nowMs >= ageThresholdMs else { return true }
@@ -182,14 +257,14 @@ final class GossipSyncManager {
removeState(for: sender)
return
}
- let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
let sender = PeerID(hexData: packet.senderID)
- latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
+ latestAnnouncementByPeer[sender] = packet
case .message:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
+ archiveDirty = true
case .fragment:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
@@ -200,6 +275,36 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
+ case .groupMessage:
+ // Opaque ciphertext to non-members; carried and served like any
+ // other broadcast so members get backfill from any relay.
+ guard isBroadcastRecipient else { return }
+ guard isPacketFresh(packet) else { return }
+ let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
+ groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
+ case .prekeyBundle:
+ // Callers only feed verified bundles here (own bundles at send
+ // time, peers' after signature verification), so gossip never
+ // spreads a bundle this node couldn't attribute.
+ guard isBroadcastRecipient else { return }
+ guard isPacketFresh(packet) else { return }
+ // Key by the bundle's authenticated identity (its noise static key),
+ // NOT the unauthenticated packet senderID. Otherwise one valid
+ // bundle re-broadcast under many fabricated sender IDs would create
+ // one cache entry each and exhaust the per-owner cap, starving
+ // legitimate bundles. One owner ⇒ at most one entry.
+ guard let bundle = PrekeyBundle.decode(packet.payload) else { return }
+ let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
+ if let existing = latestPrekeyBundleByPeer[owner],
+ existing.packet.timestamp >= packet.timestamp {
+ return
+ }
+ // Bounded owner count; replacing a known owner's bundle is always
+ // allowed so the cap can't block refreshes.
+ guard latestPrekeyBundleByPeer[owner] != nil
+ || latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
+ let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
+ latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
default:
break
}
@@ -233,11 +338,29 @@ final class GossipSyncManager {
delegate?.sendPacket(signed)
}
- private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
+ /// Targeted fragment recovery: ask connected peers for the specific
+ /// fragment streams whose reassembly has stalled, instead of waiting on
+ /// the next periodic GCS fragment round to cover them.
+ func requestMissingFragments(fragmentIDs: [Data]) {
+ queue.async { [weak self] in
+ self?._requestMissingFragments(fragmentIDs)
+ }
+ }
+
+ private func _requestMissingFragments(_ fragmentIDs: [Data]) {
+ guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
+ guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
+ SecureLogger.debug("Requesting \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .sync)
+ for peerID in connectedPeers {
+ sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
+ }
+ }
+
+ private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
-
- let payload = buildGcsPayload(for: types)
+
+ let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
var recipient = Data()
var temp = peerID.id
while temp.count >= 2 && recipient.count < 8 {
@@ -265,7 +388,17 @@ final class GossipSyncManager {
}
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
+ // A response can replay the whole store, so bound how often one peer
+ // can trigger a diff pass regardless of how fast it asks.
+ guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
+ SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
+ return
+ }
let requestedTypes = (request.types ?? .publicMessages)
+ // The requester's filter only covers packets at or after this cursor;
+ // older packets are outside the filter but not missing, and without
+ // the cursor they would be re-sent every round.
+ let since = request.sinceTimestamp
// Decode GCS into sorted set and prepare membership checker
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
func mightContain(_ id: Data) -> Bool {
@@ -273,11 +406,13 @@ final class GossipSyncManager {
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
}
+ // Announces are exempt from the since-cursor: they carry the signing
+ // keys needed to verify everything else, and there is at most one per
+ // peer, so the resend cost is negligible.
if requestedTypes.contains(.announce) {
- for (_, pair) in latestAnnouncementByPeer {
- let (idHex, pkt) = pair
+ for (_, pkt) in latestAnnouncementByPeer {
guard isPacketFresh(pkt) else { continue }
- let idBytes = Data(hexString: idHex) ?? Data()
+ let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
@@ -290,6 +425,7 @@ final class GossipSyncManager {
if requestedTypes.contains(.message) {
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
for pkt in toSendMsgs {
+ if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
@@ -301,8 +437,19 @@ final class GossipSyncManager {
}
if requestedTypes.contains(.fragment) {
+ // A fragment-ID filter narrows the diff to exactly the named
+ // fragment streams (targeted resync for stalled reassemblies)
+ // and bypasses the since-cursor for them; the GCS filter still
+ // excludes the pieces the requester already holds. Fragment
+ // payloads start with the 8-byte stream ID.
+ let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)
let frags = fragments.allPackets(isFresh: isPacketFresh)
for pkt in frags {
+ if let fragmentIdFilter {
+ guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue }
+ } else if let since, pkt.timestamp < since {
+ continue
+ }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
@@ -316,6 +463,53 @@ final class GossipSyncManager {
if requestedTypes.contains(.fileTransfer) {
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
for pkt in files {
+ if let since, pkt.timestamp < since { continue }
+ let idBytes = PacketIdUtil.computeId(pkt)
+ if !mightContain(idBytes) {
+ var toSend = pkt
+ toSend.ttl = 0
+ toSend.isRSR = true // Mark as solicited response
+ delegate?.sendPacket(to: peerID, packet: toSend)
+ }
+ }
+ }
+
+ if requestedTypes.contains(.groupMessage) {
+ let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
+ for pkt in groupPkts {
+ if let since, pkt.timestamp < since { continue }
+ let idBytes = PacketIdUtil.computeId(pkt)
+ if !mightContain(idBytes) {
+ var toSend = pkt
+ toSend.ttl = 0
+ toSend.isRSR = true // Mark as solicited response
+ delegate?.sendPacket(to: peerID, packet: toSend)
+ }
+ }
+ }
+ // Like announces, prekey bundles are exempt from the since-cursor:
+ // there is at most one per owner (newer replaces older), so the
+ // resend cost is bounded and a joining peer must be able to learn
+ // bundles generated long before it arrived.
+ if requestedTypes.contains(.prekeyBundle) {
+ for (_, pair) in latestPrekeyBundleByPeer {
+ let (idHex, pkt) = pair
+ guard isPacketFresh(pkt) else { continue }
+ let idBytes = Data(hexString: idHex) ?? Data()
+ if !mightContain(idBytes) {
+ var toSend = pkt
+ toSend.ttl = 0
+ toSend.isRSR = true // Mark as solicited response
+ delegate?.sendPacket(to: peerID, packet: toSend)
+ }
+ }
+ }
+ if requestedTypes.contains(.boardPost) {
+ // The board store already filters to live posts and tombstones;
+ // no freshness window applies (posts sync until their own expiry).
+ let boardPackets = boardPacketsProvider?() ?? []
+ for pkt in boardPackets {
+ if let since, pkt.timestamp < since { continue }
let idBytes = PacketIdUtil.computeId(pkt)
if !mightContain(idBytes) {
var toSend = pkt
@@ -328,11 +522,11 @@ final class GossipSyncManager {
}
// Build REQUEST_SYNC payload using current candidates and GCS params
- private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
+ private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
var candidates: [BitchatPacket] = []
if types.contains(.announce) {
- for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
- candidates.append(pair.packet)
+ for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
+ candidates.append(pkt)
}
}
if types.contains(.message) {
@@ -344,9 +538,20 @@ final class GossipSyncManager {
if types.contains(.fileTransfer) {
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
}
+ if types.contains(.groupMessage) {
+ candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
+ }
+ if types.contains(.prekeyBundle) {
+ for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
+ candidates.append(pair.packet)
+ }
+ }
+ if types.contains(.boardPost) {
+ candidates.append(contentsOf: boardPacketsProvider?() ?? [])
+ }
if candidates.isEmpty {
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
- let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
+ let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
return req.encode()
}
@@ -360,49 +565,113 @@ final class GossipSyncManager {
cap = max(1, config.fragmentCapacity)
} else if types == .fileTransfer {
cap = max(1, config.fileTransferCapacity)
+ } else if types == .prekeyBundle {
+ cap = max(1, config.prekeyBundleCapacity)
+ } else if types == .board {
+ cap = max(1, config.boardCapacity)
} else {
cap = max(1, config.seenCapacity)
}
let takeN = min(candidates.count, min(nMax, cap))
if takeN <= 0 {
- let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
+ let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
return req.encode()
}
- let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
+ let included = Array(candidates.prefix(takeN))
+ let ids: [Data] = included.map { PacketIdUtil.computeId($0) }
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
- let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
+ // When the filter can't cover every candidate — either the store
+ // exceeds `takeN` or the encoder trimmed the tail to fit the byte
+ // budget — tell the responder how far back the filter actually
+ // reaches. `includedCount` counts inputs in newest-first order, so the
+ // covered set is a contiguous newest-prefix and the oldest included
+ // timestamp is an exact cursor. Packets older than it are outside the
+ // filter but not missing; without the cursor the responder would
+ // re-send that entire tail every round.
+ let covered = params.includedCount
+ let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
+ ? included[covered - 1].timestamp
+ : nil
+ let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
return req.encode()
}
// Periodic cleanup of expired messages and announcements
private func cleanupExpiredMessages() {
// Remove expired announcements
- latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
- isPacketFresh(pair.packet)
+ latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in
+ isPacketFresh(pkt)
}
+ let messageCountBefore = messages.packets.count
messages.removeExpired(isFresh: isPacketFresh)
+ if messages.packets.count != messageCountBefore {
+ archiveDirty = true
+ }
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
+ groupMessages.removeExpired(isFresh: isPacketFresh)
+ latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
+ isPacketFresh(pair.packet)
+ }
+ }
+
+ // MARK: - Archive (public message persistence)
+
+ /// Rebuild the public message store from disk on launch, dropping
+ /// anything that aged out while the app was dead.
+ private func restoreArchivedMessages() {
+ guard let archive else { return }
+ var restored = 0
+ for data in archive.load() {
+ guard let packet = BitchatPacket.from(data),
+ packet.type == MessageType.message.rawValue,
+ isPacketFresh(packet) else { continue }
+ let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
+ messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
+ restored += 1
+ }
+ if restored > 0 {
+ SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
+ archiveDirty = true
+ }
+ }
+
+ private func persistArchiveIfDirty() {
+ guard archiveDirty, let archive else { return }
+ archiveDirty = false
+ let packets = messages.allPackets(isFresh: isPacketFresh)
+ .compactMap { $0.toBinaryData(padding: false) }
+ archive.save(packets)
+ }
+
+ /// Flush the archive outside the maintenance cadence (app backgrounding).
+ func persistNow() {
+ queue.async { [weak self] in
+ self?.persistArchiveIfDirty()
+ }
}
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
+ persistArchiveIfDirty()
requestSyncManager.cleanup() // Cleanup expired sync requests
+ responseRateLimiter.prune(now: now)
- var dueTypes: SyncTypeFlags = []
+ // One request per due schedule rather than a union filter: each type
+ // group gets the full GCS capacity and its own since-cursor, so heavy
+ // fragment traffic can't crowd messages out of the filter.
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
+ // No board source wired up means nothing to offer or store;
+ // skip the round entirely.
+ if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
- dueTypes.formUnion(syncSchedules[index].types)
+ sendPeriodicSync(for: syncSchedules[index].types)
}
}
-
- if !dueTypes.isEmpty {
- sendPeriodicSync(for: dueTypes)
- }
}
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
@@ -418,8 +687,8 @@ final class GossipSyncManager {
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMs >= timeoutMs else { return }
let cutoff = nowMs - timeoutMs
- let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
- pair.packet.timestamp < cutoff ? peerID : nil
+ let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in
+ pkt.timestamp < cutoff ? peerID : nil
}
guard !stalePeerIDs.isEmpty else { return }
for peerKey in stalePeerIDs {
@@ -435,10 +704,17 @@ final class GossipSyncManager {
}
private func removeState(for peerID: PeerID) {
+ // Deliberately keeps the peer's prekey bundle: bundles exist to reach
+ // owners who left the mesh, and they age out on their own schedule.
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
+ let messageCountBefore = messages.packets.count
messages.remove { PeerID(hexData: $0.senderID) == peerID }
+ if messages.packets.count != messageCountBefore {
+ archiveDirty = true
+ }
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
+ groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
}
}
@@ -456,6 +732,12 @@ extension GossipSyncManager {
}
}
+ func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
+ queue.sync {
+ latestPrekeyBundleByPeer[peerID] != nil
+ }
+ }
+
func _messageCount(for peerID: PeerID) -> Int {
queue.sync {
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
diff --git a/bitchat/Sync/SyncResponseRateLimiter.swift b/bitchat/Sync/SyncResponseRateLimiter.swift
new file mode 100644
index 00000000..a6ffc6a1
--- /dev/null
+++ b/bitchat/Sync/SyncResponseRateLimiter.swift
@@ -0,0 +1,42 @@
+import BitFoundation
+import Foundation
+
+/// Sliding-window limiter for REQUEST_SYNC responses.
+///
+/// A single sync response can replay the entire gossip store, so a peer that
+/// requests in a tight loop must not be able to drain the airtime and battery
+/// of everyone in radio range. Legitimate peers send at most a few requests
+/// per maintenance tick (one per type schedule, plus the initial sync).
+struct SyncResponseRateLimiter {
+ private let maxResponses: Int
+ private let window: TimeInterval
+ private var history: [PeerID: [Date]] = [:]
+
+ init(maxResponses: Int, window: TimeInterval) {
+ self.maxResponses = max(1, maxResponses)
+ self.window = max(0, window)
+ }
+
+ /// Returns true (and records the response) if the peer is under its
+ /// response budget for the current window.
+ mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool {
+ let cutoff = now.addingTimeInterval(-window)
+ var recent = (history[peerID] ?? []).filter { $0 >= cutoff }
+ guard recent.count < maxResponses else {
+ history[peerID] = recent
+ return false
+ }
+ recent.append(now)
+ history[peerID] = recent
+ return true
+ }
+
+ /// Drops history outside the window so departed peers don't accumulate.
+ mutating func prune(now: Date) {
+ let cutoff = now.addingTimeInterval(-window)
+ history = history.compactMapValues { dates in
+ let recent = dates.filter { $0 >= cutoff }
+ return recent.isEmpty ? nil : recent
+ }
+ }
+}
diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift
index 430485a0..3b4b0c82 100644
--- a/bitchat/Sync/SyncTypeFlags.swift
+++ b/bitchat/Sync/SyncTypeFlags.swift
@@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet {
let rawValue: UInt64
init(rawValue: UInt64) {
- self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
+ // Drop any bit that doesn't map to a known message type. Wire data can
+ // carry up to 8 bytes of flags; without this mask, bits with no type
+ // (a truncated/garbled field, or a type a newer peer added) would live
+ // in the set as phantom membership that no `contains` check matches and
+ // `toData` re-serializes — a meaningless "accepted but does nothing"
+ // state. Masking here keeps every instance normalized at the source.
+ self.rawValue = rawValue & SyncTypeFlags.knownTypeMask
}
+ /// Union of every bit that maps to a message type. Derived from the
+ /// bit↔type table so it tracks automatically when a type is added.
+ private static let knownTypeMask: UInt64 = {
+ var mask: UInt64 = 0
+ for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil {
+ mask |= (1 << UInt64(bit))
+ }
+ return mask
+ }()
+
private static func bitIndex(for type: MessageType) -> Int? {
switch type {
case .announce: return 0
@@ -20,9 +36,29 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
+ case .boardPost: return 8
+ // Extended bits are compat-safe by construction: `toData()` encodes
+ // the bitfield little-endian with trailing zero bytes trimmed (bit 10
+ // widens the wire form from 1 to 2 bytes inside the length-prefixed
+ // REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
+ // `type(forBit:)` maps unknown bits to nil — so old clients simply
+ // ignore the group bit and answer with the types they know.
+ case .groupMessage: return 10
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
+ // Ping/pong are ephemeral directed probes; replaying them via gossip
+ // sync would only produce stale, unanswerable echoes.
+ case .ping, .pong: return nil
+ // Gateway carriers are ephemeral live traffic (uplinks are directed,
+ // downlinks are rate-budgeted rebroadcasts); replaying them via sync
+ // would waste airtime and extend their lifetime.
+ case .nostrCarrier: return nil
+ // Prekey bundles gossip like board posts. The bitfield is a
+ // wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
+ // ignored by `type(forBit:)`), so bits 8+ need no format change: old
+ // clients decode the wider flags and simply never match the new bits.
+ case .prekeyBundle: return 9
}
}
@@ -36,6 +72,12 @@ struct SyncTypeFlags: OptionSet {
case 5: return .fragment
case 6: return .requestSync
case 7: return .fileTransfer
+ // Bit 8 spills the encoded bitfield into a second byte. Decoders since
+ // type-aware sync (#853) accept 1-8 bytes and map unknown bits to no
+ // known type, so old clients ignore board rounds instead of choking.
+ case 8: return .boardPost
+ case 9: return .prekeyBundle
+ case 10: return .groupMessage
default:
return nil
}
@@ -45,6 +87,9 @@ struct SyncTypeFlags: OptionSet {
static let message = SyncTypeFlags(messageTypes: [.message])
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
+ static let board = SyncTypeFlags(messageTypes: [.boardPost])
+ static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
+ static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
diff --git a/bitchat/ViewModels/ChatGroupCoordinator.swift b/bitchat/ViewModels/ChatGroupCoordinator.swift
new file mode 100644
index 00000000..a83cb024
--- /dev/null
+++ b/bitchat/ViewModels/ChatGroupCoordinator.swift
@@ -0,0 +1,602 @@
+import BitFoundation
+import BitLogger
+import Foundation
+
+/// The narrow surface `ChatGroupCoordinator` needs from its owner.
+///
+/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
+/// minimal context it actually uses instead of holding an `unowned` back-ref
+/// to the whole `ChatViewModel`. Group chats are keyed like direct chats
+/// (virtual "group_" peer IDs), so the conversation intents below reuse the
+/// private-chat store operations.
+@MainActor
+protocol ChatGroupContext: AnyObject {
+ // MARK: Identity & state
+ var nickname: String { get }
+ var myPeerID: PeerID { get }
+ var selectedPrivateChatPeer: PeerID? { get }
+ var groupStore: GroupStore { get }
+
+ /// Fingerprint of our own Noise static identity key.
+ func myNoiseFingerprint() -> String
+ /// Our Ed25519 signing public key.
+ func mySigningPublicKey() -> Data
+ /// Signs `data` with our Noise signing key.
+ func signWithNoiseKey(_ data: Data) -> Data?
+
+ // MARK: Peers
+ func getPeerIDForNickname(_ nickname: String) -> PeerID?
+ func isPeerConnected(_ peerID: PeerID) -> Bool
+ func peerNickname(for peerID: PeerID) -> String?
+ /// The peer's Noise fingerprint from the live session/registry.
+ func meshFingerprint(for peerID: PeerID) -> String?
+ /// The peer's persisted crypto identity (fingerprint + signing key), if
+ /// the identity store has a signature-verified announce for them.
+ func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)?
+ /// The connected short peer ID whose fingerprint matches, if any.
+ func connectedPeerID(forFingerprint fingerprint: String) -> PeerID?
+ /// Whether the user has blocked the identity with this Noise fingerprint.
+ func isFingerprintBlocked(_ fingerprint: String) -> Bool
+
+ // MARK: Transport
+ func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID)
+ func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID)
+ func broadcastGroupMessagePayload(_ payload: Data)
+
+ // MARK: Conversation intents (group chats are direct-keyed)
+ @discardableResult
+ func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
+ func markPrivateChatUnread(_ peerID: PeerID)
+ func removePrivateChat(_ peerID: PeerID)
+ func startPrivateChat(with peerID: PeerID)
+ func endPrivateChat()
+ func addSystemMessage(_ content: String)
+ func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
+ func notifyUIChanged()
+ func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
+}
+
+extension ChatViewModel: ChatGroupContext {
+ // `nickname`, `myPeerID`, `selectedPrivateChatPeer`, `groupStore`,
+ // `getPeerIDForNickname(_:)`, `isPeerConnected(_:)`, `peerNickname(for:)`,
+ // `appendPrivateMessage(_:to:)`, `markPrivateChatUnread(_:)`,
+ // `removePrivateChat(_:)`, `startPrivateChat(with:)`,
+ // `addSystemMessage(_:)`, `addLocalPrivateSystemMessage(_:to:)`,
+ // `notifyUIChanged()`, and `notifyPrivateMessage(from:message:peerID:)`
+ // are shared requirements with the other contexts or satisfied by
+ // existing `ChatViewModel` members. The members below flatten nested
+ // service accesses into intent-named calls.
+
+ func myNoiseFingerprint() -> String {
+ meshService.noiseIdentityFingerprint()
+ }
+
+ func mySigningPublicKey() -> Data {
+ meshService.noiseSigningPublicKeyData()
+ }
+
+ func signWithNoiseKey(_ data: Data) -> Data? {
+ meshService.noiseSignData(data)
+ }
+
+ func meshFingerprint(for peerID: PeerID) -> String? {
+ meshService.getFingerprint(for: peerID)
+ }
+
+ /// The persisted, signature-verified identity behind a short mesh peer
+ /// ID. Cross-checked against the live session fingerprint so a roster
+ /// entry can never be pinned to a signing key from a different identity.
+ func cryptoIdentity(for peerID: PeerID) -> (fingerprint: String, signingKey: Data)? {
+ guard let fingerprint = meshService.getFingerprint(for: peerID) else { return nil }
+ let candidates = identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
+ guard let identity = candidates.first(where: { $0.fingerprint == fingerprint }),
+ let signingKey = identity.signingPublicKey else { return nil }
+ return (fingerprint, signingKey)
+ }
+
+ /// Short mesh peer IDs are the fingerprint's first 16 hex chars, so the
+ /// connected peer for a roster fingerprint is a direct derivation.
+ func connectedPeerID(forFingerprint fingerprint: String) -> PeerID? {
+ let shortID = PeerID(str: String(fingerprint.prefix(16)))
+ return meshService.isPeerConnected(shortID) ? shortID : nil
+ }
+
+ func isFingerprintBlocked(_ fingerprint: String) -> Bool {
+ identityManager.isBlocked(fingerprint: fingerprint)
+ }
+
+ func sendGroupInvitePayload(_ payload: Data, to peerID: PeerID) {
+ meshService.sendGroupInvite(payload, to: peerID)
+ }
+
+ func sendGroupKeyUpdatePayload(_ payload: Data, to peerID: PeerID) {
+ meshService.sendGroupKeyUpdate(payload, to: peerID)
+ }
+
+ func broadcastGroupMessagePayload(_ payload: Data) {
+ meshService.broadcastGroupMessage(payload)
+ }
+
+ // MARK: CommandContextProvider group commands (parsed by CommandProcessor)
+
+ func groupCreate(named name: String) -> CommandResult {
+ groupCoordinator.createGroup(named: name)
+ }
+
+ func groupInvite(nickname: String) -> CommandResult {
+ groupCoordinator.inviteMember(nickname: nickname)
+ }
+
+ func groupRemove(nickname: String) -> CommandResult {
+ groupCoordinator.removeMember(nickname: nickname)
+ }
+
+ func groupLeave() -> CommandResult {
+ groupCoordinator.leaveGroup()
+ }
+
+ func groupList() -> CommandResult {
+ groupCoordinator.listGroups()
+ }
+}
+
+/// Owns the private-groups feature: creating groups, creator-managed invites
+/// and key rotation over Noise, and sealing/opening group message broadcasts.
+/// Delivery is fire-and-flood like public chat — no per-member acks in v1 —
+/// with gossip-sync backfill as the only offline catch-up.
+@MainActor
+final class ChatGroupCoordinator {
+ private unowned let context: any ChatGroupContext
+
+ private static let maxGroupNameLength = 40
+
+ init(context: any ChatGroupContext) {
+ self.context = context
+ }
+
+ // MARK: - Commands
+
+ func createGroup(named rawName: String) -> CommandResult {
+ let name = rawName.trimmed
+ guard !name.isEmpty else {
+ return .error(message: String(localized: "system.group.usage_create", comment: "Usage hint for /group create"))
+ }
+ guard name.count <= Self.maxGroupNameLength else {
+ return .error(message: String(localized: "system.group.name_too_long", comment: "Error when a group name exceeds the length cap"))
+ }
+
+ let myFingerprint = context.myNoiseFingerprint()
+ let mySigningKey = context.mySigningPublicKey()
+ guard !myFingerprint.isEmpty, mySigningKey.count == 32 else {
+ return .error(message: String(localized: "system.group.identity_unavailable", comment: "Error when the local identity is not ready for group operations"))
+ }
+
+ let creator = GroupMember(fingerprint: myFingerprint, signingKey: mySigningKey, nickname: context.nickname)
+ guard let group = context.groupStore.createGroup(named: name, creator: creator) else {
+ return .error(message: String(localized: "system.group.create_failed", comment: "Error when group creation fails"))
+ }
+
+ context.startPrivateChat(with: group.peerID)
+ return .success(message: String(
+ format: String(localized: "system.group.created", comment: "System message after creating a group; placeholder is the group name"),
+ locale: .current,
+ name
+ ))
+ }
+
+ func inviteMember(nickname rawNickname: String) -> CommandResult {
+ let nickname = normalizedNickname(rawNickname)
+ guard !nickname.isEmpty else {
+ return .error(message: String(localized: "system.group.usage_invite", comment: "Usage hint for /group invite"))
+ }
+ guard let group = selectedGroup() else {
+ return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
+ }
+ guard group.creatorFingerprint == context.myNoiseFingerprint() else {
+ return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
+ }
+ guard let peerID = context.getPeerIDForNickname(nickname) else {
+ return .error(message: String(
+ format: String(localized: "system.group.peer_not_found", comment: "Error when the invitee nickname is unknown; placeholder is the nickname"),
+ locale: .current,
+ nickname
+ ))
+ }
+ guard context.isPeerConnected(peerID) else {
+ return .error(message: String(
+ format: String(localized: "system.group.peer_not_connected", comment: "Error when the invitee is not connected over mesh; placeholder is the nickname"),
+ locale: .current,
+ nickname
+ ))
+ }
+ guard let identity = context.cryptoIdentity(for: peerID) else {
+ return .error(message: String(
+ format: String(localized: "system.group.peer_identity_unknown", comment: "Error when the invitee's verified identity is unavailable; placeholder is the nickname"),
+ locale: .current,
+ nickname
+ ))
+ }
+ guard !group.isMember(fingerprint: identity.fingerprint) else {
+ return .error(message: String(
+ format: String(localized: "system.group.already_member", comment: "Error when the invitee is already a member; placeholder is the nickname"),
+ locale: .current,
+ nickname
+ ))
+ }
+ guard group.members.count < BitchatGroup.maxMembers else {
+ return .error(message: String(
+ format: String(localized: "system.group.full", comment: "Error when the group is at the member cap; placeholder is the cap"),
+ locale: .current,
+ "\(BitchatGroup.maxMembers)"
+ ))
+ }
+
+ let newMember = GroupMember(
+ fingerprint: identity.fingerprint,
+ signingKey: identity.signingKey,
+ nickname: context.peerNickname(for: peerID) ?? nickname
+ )
+ // Rotate the key (epoch + 1) on every roster change, not just removals.
+ // A monotonically increasing epoch per roster gives the receiver a
+ // strict ordering: two out-of-order invite states can no longer share
+ // an epoch and last-writer-wins a just-added member back out.
+ let members = group.members + [newMember]
+ guard let (updated, key) = context.groupStore.rotateKey(groupID: group.groupID, members: members),
+ let payload = signedStatePayload(for: updated, key: key) else {
+ return .error(message: String(localized: "system.group.invite_failed", comment: "Error when building or signing a group invite fails"))
+ }
+
+ context.sendGroupInvitePayload(payload, to: peerID)
+ distributeState(payload, group: updated, excluding: [identity.fingerprint], type: .keyUpdate)
+
+ return .success(message: String(
+ format: String(localized: "system.group.invited", comment: "System message after inviting someone; placeholders are the nickname and the group name"),
+ locale: .current,
+ nickname,
+ updated.name
+ ))
+ }
+
+ /// Creator-side removal: rotates the group key (epoch + 1) and sends the
+ /// new state to every remaining member so the removed member's key stops
+ /// decrypting future traffic.
+ func removeMember(nickname rawNickname: String) -> CommandResult {
+ let nickname = normalizedNickname(rawNickname)
+ guard !nickname.isEmpty else {
+ return .error(message: String(localized: "system.group.usage_remove", comment: "Usage hint for /group remove"))
+ }
+ guard let group = selectedGroup() else {
+ return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
+ }
+ guard group.creatorFingerprint == context.myNoiseFingerprint() else {
+ return .error(message: String(localized: "system.group.creator_only", comment: "Error when a non-creator attempts a creator-only group action"))
+ }
+ guard let member = group.members.first(where: { $0.nickname.caseInsensitiveCompare(nickname) == .orderedSame }) else {
+ return .error(message: String(
+ format: String(localized: "system.group.member_not_found", comment: "Error when the member to remove is not in the roster; placeholder is the nickname"),
+ locale: .current,
+ nickname
+ ))
+ }
+ guard member.fingerprint != group.creatorFingerprint else {
+ return .error(message: String(localized: "system.group.cannot_remove_creator", comment: "Error when the creator tries to remove themselves"))
+ }
+
+ let remaining = group.members.filter { $0.fingerprint != member.fingerprint }
+ guard let (rotated, newKey) = context.groupStore.rotateKey(groupID: group.groupID, members: remaining),
+ let payload = signedStatePayload(for: rotated, key: newKey) else {
+ return .error(message: String(localized: "system.group.rotate_failed", comment: "Error when rotating the group key fails"))
+ }
+
+ distributeState(payload, group: rotated, excluding: [], type: .keyUpdate)
+ notifyRemovedMember(member, rotated: rotated)
+
+ return .success(message: String(
+ format: String(localized: "system.group.removed_member", comment: "System message after removing a member and rotating the key; placeholder is the nickname"),
+ locale: .current,
+ member.nickname
+ ))
+ }
+
+ func leaveGroup() -> CommandResult {
+ guard let group = selectedGroup() else {
+ return .error(message: String(localized: "system.group.not_in_group", comment: "Error when a group command requires an open group chat"))
+ }
+ // Close the chat window first so the confirmation message doesn't
+ // resurrect the conversation we're about to remove.
+ context.endPrivateChat()
+ context.removePrivateChat(group.peerID)
+ context.groupStore.removeGroup(withID: group.groupID)
+ context.notifyUIChanged()
+ return .success(message: String(
+ format: String(localized: "system.group.left", comment: "System message after leaving a group; placeholder is the group name"),
+ locale: .current,
+ group.name
+ ))
+ }
+
+ func listGroups() -> CommandResult {
+ let groups = context.groupStore.groups
+ guard !groups.isEmpty else {
+ return .success(message: String(localized: "system.group.none", comment: "System message when the user is in no groups"))
+ }
+ let myFingerprint = context.myNoiseFingerprint()
+ let lines = groups.map { group -> String in
+ let role = group.creatorFingerprint == myFingerprint ? " (creator)" : ""
+ return "#\(group.name)\(role) — \(group.members.count)/\(BitchatGroup.maxMembers)"
+ }
+ return .success(message: String(localized: "system.group.list_header", comment: "Header line for the /group list output") + "\n" + lines.joined(separator: "\n"))
+ }
+
+ // MARK: - Sending
+
+ /// Fire-and-flood send: local echo goes straight to `.sent` because group
+ /// messages have no per-member acknowledgments in v1.
+ func sendGroupMessage(_ content: String, to groupPeerID: PeerID) {
+ guard !content.isEmpty, content.count <= InputValidator.Limits.maxMessageLength else { return }
+ guard let group = context.groupStore.group(for: groupPeerID),
+ let key = context.groupStore.key(forGroupID: group.groupID) else {
+ context.addSystemMessage(String(localized: "system.group.unknown", comment: "System message when sending into an unknown group"))
+ return
+ }
+
+ let messageID = UUID().uuidString
+ let timestamp = Date()
+ let payload: Data
+ do {
+ payload = try GroupCrypto.sealMessage(
+ content: content,
+ messageID: messageID,
+ senderNickname: context.nickname,
+ senderSigningKey: context.mySigningPublicKey(),
+ timestampMs: UInt64(timestamp.timeIntervalSince1970 * 1000),
+ groupID: group.groupID,
+ epoch: group.epoch,
+ key: key,
+ sign: { [weak context] data in context?.signWithNoiseKey(data) }
+ )
+ } catch {
+ SecureLogger.error("Failed to seal group message: \(error)", category: .encryption)
+ context.addLocalPrivateSystemMessage(
+ String(localized: "system.group.send_failed", comment: "System message when sealing a group message fails"),
+ to: groupPeerID
+ )
+ return
+ }
+
+ let message = BitchatMessage(
+ id: messageID,
+ sender: context.nickname,
+ content: content,
+ timestamp: timestamp,
+ isRelay: false,
+ originalSender: nil,
+ isPrivate: true,
+ recipientNickname: group.name,
+ senderPeerID: context.myPeerID,
+ mentions: nil,
+ deliveryStatus: .sent
+ )
+ context.appendPrivateMessage(message, to: groupPeerID)
+ context.broadcastGroupMessagePayload(payload)
+ context.notifyUIChanged()
+ }
+
+ // MARK: - Receiving
+
+ /// Decrypt-verify path for an incoming 0x25 broadcast. Drops silently for
+ /// unknown groups (non-members relay but never read), wrong epochs, bad
+ /// sender signatures, and senders missing from the pinned roster.
+ func handleGroupMessagePayload(_ payload: Data, timestamp: Date) {
+ guard let envelope = GroupMessageEnvelope.decode(payload) else { return }
+ guard let group = context.groupStore.group(withID: envelope.groupID) else { return }
+ guard envelope.epoch == group.epoch else {
+ SecureLogger.debug("Dropping group message with epoch \(envelope.epoch) (current \(group.epoch))", category: .encryption)
+ return
+ }
+ guard let key = context.groupStore.key(forGroupID: group.groupID) else { return }
+
+ let plaintext: GroupMessagePlaintext
+ do {
+ plaintext = try GroupCrypto.openMessage(envelope, key: key)
+ } catch {
+ SecureLogger.debug("Failed to open group message: \(error)", category: .encryption)
+ return
+ }
+
+ // Sender must be pinned in the creator-signed roster; key possession
+ // alone is not authorship.
+ guard let member = group.member(withSigningKey: plaintext.senderSigningKey) else {
+ SecureLogger.warning("Dropping group message from non-roster sender", category: .security)
+ return
+ }
+ // Our own broadcast echoed back via relay or sync replay.
+ guard plaintext.senderSigningKey != context.mySigningPublicKey() else { return }
+ // Honor /block inside groups too: drop display + notification for a
+ // blocked member, consistent with every other inbound path.
+ guard !context.isFingerprintBlocked(member.fingerprint) else {
+ SecureLogger.debug("Dropping group message from blocked member", category: .security)
+ return
+ }
+
+ let groupPeerID = group.peerID
+ // Trust the authenticated inner timestamp (clamped so a future-dated
+ // message cannot pin itself to the bottom of the timeline).
+ let messageDate = min(Date(timeIntervalSince1970: TimeInterval(plaintext.timestampMs) / 1000), Date())
+ let senderName = member.nickname.isEmpty ? plaintext.senderNickname : member.nickname
+ let senderPeerID = PeerID(str: String(member.fingerprint.prefix(16)))
+ let message = BitchatMessage(
+ id: plaintext.messageID,
+ sender: senderName,
+ content: plaintext.content,
+ timestamp: messageDate,
+ isRelay: false,
+ originalSender: nil,
+ isPrivate: true,
+ recipientNickname: group.name,
+ senderPeerID: senderPeerID,
+ mentions: nil
+ )
+
+ guard context.appendPrivateMessage(message, to: groupPeerID) else { return }
+
+ let isViewing = context.selectedPrivateChatPeer == groupPeerID
+ if !isViewing {
+ context.markPrivateChatUnread(groupPeerID)
+ let isRecent = Date().timeIntervalSince(messageDate) < 30
+ if isRecent {
+ context.notifyPrivateMessage(
+ from: "\(senderName) @ \(group.name)",
+ message: plaintext.content,
+ peerID: groupPeerID
+ )
+ }
+ }
+ context.notifyUIChanged()
+ }
+
+ /// Accepts creator-signed group state arriving as an invite. The Noise
+ /// session peer must BE the creator, the signature must verify against
+ /// the creator key pinned in the roster, and we must be in the roster.
+ func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
+ applyGroupState(from: peerID, payload: payload, isInvite: true)
+ }
+
+ /// Accepts creator-signed state updates (rotation/roster). A state whose
+ /// roster no longer includes us means we were removed: drop the group.
+ func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
+ applyGroupState(from: peerID, payload: payload, isInvite: false)
+ }
+}
+
+private extension ChatGroupCoordinator {
+ enum StateSendType {
+ case invite
+ case keyUpdate
+ }
+
+ func normalizedNickname(_ raw: String) -> String {
+ let trimmed = raw.trimmed
+ return trimmed.hasPrefix("@") ? String(trimmed.dropFirst()) : trimmed
+ }
+
+ func selectedGroup() -> BitchatGroup? {
+ guard let selected = context.selectedPrivateChatPeer, selected.isGroup else { return nil }
+ return context.groupStore.group(for: selected)
+ }
+
+ func signedStatePayload(for group: BitchatGroup, key: Data) -> Data? {
+ GroupStatePayload.makeSigned(group: group, key: key) { [weak context] data in
+ context?.signWithNoiseKey(data)
+ }?.encode()
+ }
+
+ /// Sends the state payload to every connected roster member except us and
+ /// the excluded fingerprints. Offline members catch up the next time the
+ /// creator sends them state (v1 limitation, documented in the PR).
+ func distributeState(_ payload: Data, group: BitchatGroup, excluding excludedFingerprints: Set, type: StateSendType) {
+ let myFingerprint = context.myNoiseFingerprint()
+ for member in group.members {
+ guard member.fingerprint != myFingerprint,
+ !excludedFingerprints.contains(member.fingerprint),
+ let peerID = context.connectedPeerID(forFingerprint: member.fingerprint) else { continue }
+ switch type {
+ case .invite:
+ context.sendGroupInvitePayload(payload, to: peerID)
+ case .keyUpdate:
+ context.sendGroupKeyUpdatePayload(payload, to: peerID)
+ }
+ }
+ }
+
+ /// Tells a just-removed member they're out so their client can deactivate
+ /// the group instead of silently going dark (dropping every message under
+ /// the epoch it no longer has the key for). The notice is a creator-signed
+ /// state whose roster excludes the removee — their `applyGroupState`
+ /// removal branch fires on the missing-self roster and surfaces the
+ /// "removed from group" system message.
+ ///
+ /// It carries a throwaway all-zero key, never the rotated key, so the
+ /// removee cannot decrypt post-removal traffic. State is sent 1:1 over
+ /// authenticated Noise, so no remaining member ever receives this blob
+ /// (and even if one did, its own missing-self check would not match).
+ /// If the removee is offline the notice can't be delivered — same v1
+ /// limitation as any other missed key update, documented in the PR.
+ func notifyRemovedMember(_ removed: GroupMember, rotated: BitchatGroup) {
+ guard let peerID = context.connectedPeerID(forFingerprint: removed.fingerprint) else { return }
+ let throwawayKey = Data(count: BitchatGroup.keyLength)
+ guard let payload = signedStatePayload(for: rotated, key: throwawayKey) else { return }
+ context.sendGroupKeyUpdatePayload(payload, to: peerID)
+ }
+
+ func applyGroupState(from peerID: PeerID, payload: Data, isInvite: Bool) {
+ guard let state = GroupStatePayload.decode(payload) else {
+ SecureLogger.warning("Malformed group state payload from \(peerID.id.prefix(8))…", category: .security)
+ return
+ }
+ // The Noise session already authenticated `peerID`; require that the
+ // authenticated peer IS the creator whose key signed the state, so a
+ // member can't re-invite or rotate on the creator's behalf.
+ guard let senderFingerprint = context.meshFingerprint(for: peerID),
+ senderFingerprint == state.creatorFingerprint else {
+ SecureLogger.warning("Dropping group state from non-creator \(peerID.id.prefix(8))…", category: .security)
+ return
+ }
+ guard state.verifyCreatorSignature() else {
+ SecureLogger.warning("Dropping group state with invalid creator signature", category: .security)
+ return
+ }
+
+ let myFingerprint = context.myNoiseFingerprint()
+ let existing = context.groupStore.group(withID: state.groupID)
+
+ // A creator-signed roster that no longer includes us is a removal.
+ guard state.members.contains(where: { $0.fingerprint == myFingerprint }) else {
+ if let existing {
+ if context.selectedPrivateChatPeer == existing.peerID {
+ context.endPrivateChat()
+ }
+ context.removePrivateChat(existing.peerID)
+ context.groupStore.removeGroup(withID: existing.groupID)
+ context.addSystemMessage(String(
+ format: String(localized: "system.group.removed_from", comment: "System message when removed from a group; placeholder is the group name"),
+ locale: .current,
+ existing.name
+ ))
+ context.notifyUIChanged()
+ }
+ return
+ }
+
+ // Never regress the epoch: state travels over live Noise sessions,
+ // so an older epoch here is a stale (or misbehaving) creator device.
+ if let existing, state.epoch < existing.epoch {
+ SecureLogger.warning("Dropping stale group state (epoch \(state.epoch) < \(existing.epoch))", category: .security)
+ return
+ }
+
+ let isNewMembership = existing == nil
+ guard context.groupStore.upsert(state.asGroup, key: state.key) else {
+ SecureLogger.error("Failed to store group state for \(state.name)", category: .session)
+ return
+ }
+
+ if isNewMembership {
+ let inviter = state.members.first { $0.fingerprint == state.creatorFingerprint }?.nickname
+ ?? context.peerNickname(for: peerID)
+ ?? "?"
+ let notice = String(
+ format: String(localized: "system.group.joined", comment: "System message when added to a group; placeholders are the group name and the inviter"),
+ locale: .current,
+ state.name,
+ inviter
+ )
+ context.addSystemMessage(notice)
+ context.markPrivateChatUnread(state.asGroup.peerID)
+ context.notifyPrivateMessage(from: inviter, message: notice, peerID: state.asGroup.peerID)
+ } else if isInvite == false, let existing, state.epoch > existing.epoch {
+ SecureLogger.info("Group '\(state.name)' rotated to epoch \(state.epoch)", category: .session)
+ }
+ context.notifyUIChanged()
+ }
+}
diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift
index bea1b5b2..1241f71b 100644
--- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift
+++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift
@@ -185,6 +185,13 @@ final class ChatLifecycleCoordinator {
func markPrivateMessagesAsRead(from peerID: PeerID) {
context.markChatAsRead(from: peerID)
+ // Group chats are keyed under a virtual group_ peerID; no member IS the
+ // conversation peer, so the receipt loops below (which gate on
+ // senderPeerID == peerID) must never emit a read/delivered receipt for
+ // one. This guard makes that explicit so a future refactor of the
+ // receipt matching can't silently start leaking receipts into groups.
+ guard !peerID.isGroup else { return }
+
if peerID.isGeoDM,
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
@@ -324,7 +331,7 @@ private extension ChatLifecycleCoordinator {
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
- let event = try NostrProtocol.createEphemeralGeohashEvent(
+ let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift
index 915ea7fa..8d8b6a9e 100644
--- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift
+++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift
@@ -68,10 +68,22 @@ extension ChatViewModel: ChatOutgoingContext {
final class ChatOutgoingCoordinator {
private unowned let context: any ChatOutgoingContext
+ /// In-flight NIP-13 mining for the most recent geohash send. A newer send
+ /// (or leaving the channel) cancels it, which only expedites the mining —
+ /// the message still goes out at the difficulty already reached.
+ /// (Read access is internal so tests can await the send's completion.)
+ private(set) var geohashMiningTask: Task?
+
init(context: any ChatOutgoingContext) {
self.context = context
}
+ /// Finish any in-flight geohash PoW mining early (the pending message
+ /// still sends, at whatever committed difficulty it reached).
+ func expeditePendingGeohashMining() {
+ geohashMiningTask?.cancel()
+ }
+
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
@@ -92,120 +104,117 @@ final class ChatOutgoingCoordinator {
}
let mentions = context.parseMentions(from: content)
- let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
- guard let preparedMessage else { return }
- appendLocalEcho(preparedMessage.message)
- routePublicMessage(
- originalContent: content,
- mentions: mentions,
- geoContext: preparedMessage.geoContext,
- messageID: preparedMessage.message.id,
- timestamp: preparedMessage.message.timestamp
- )
+ switch context.activeChannel {
+ case .mesh:
+ sendMeshPublicMessage(originalContent: content, trimmed: trimmed, mentions: mentions)
+ case .location(let channel):
+ sendGeohashPublicMessage(trimmed, mentions: mentions, channel: channel)
+ }
}
}
private extension ChatOutgoingCoordinator {
- func preparePublicMessage(
- content: String,
- trimmed: String,
- mentions: [String]
- ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
- var geoContext: ChatViewModel.GeoOutgoingContext?
- var displaySender = context.nickname
- var localSenderPeerID = context.myPeerID
- var messageID: String?
- var messageTimestamp = Date()
+ func sendMeshPublicMessage(originalContent: String, trimmed: String, mentions: [String]) {
+ let message = BitchatMessage(
+ sender: context.nickname,
+ content: trimmed,
+ timestamp: Date(),
+ isRelay: false,
+ senderPeerID: context.myPeerID,
+ mentions: mentions.isEmpty ? nil : mentions
+ )
- switch context.activeChannel {
- case .mesh:
- break
+ appendLocalEcho(message, to: .mesh)
+ context.recordPublicActivity(forChannelKey: "mesh")
+ context.sendMeshMessage(
+ originalContent,
+ mentions: mentions,
+ messageID: message.id,
+ timestamp: message.timestamp
+ )
+ }
- case .location(let channel):
+ /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
+ /// `NostrPoW`), so the whole echo-and-send runs in a task once the signed
+ /// event — whose ID is also the local message ID — exists. Typical mining
+ /// at the default target is well under 100 ms and hard-capped at
+ /// `NostrPoW.miningTimeCap`, so sending is never meaningfully delayed.
+ func sendGeohashPublicMessage(_ trimmed: String, mentions: [String], channel: GeohashChannel) {
+ let identity: NostrIdentity
+ do {
+ identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
+ } catch {
+ SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
+ context.addSystemMessage(
+ String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
+ )
+ return
+ }
+
+ let displaySender = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
+ let senderPeerID = PeerID(nostr: identity.publicKeyHex)
+ let teleported = context.isTeleported
+ let nickname = context.nickname
+
+ // Serialize geohash sends: each send awaits the previous send's task
+ // before it appends + relays, so user-visible order always matches
+ // send order even when an earlier message mines longer than a later
+ // one. Cancelling the previous task only *expedites* its mining (the
+ // NIP-13 target is polled, not aborted), so it still finishes and
+ // sends — and it finishes fast, so awaiting it never stacks mining
+ // delays or blocks a send beyond `NostrPoW.miningTimeCap`.
+ let previousSend = geohashMiningTask
+ previousSend?.cancel()
+ geohashMiningTask = Task { @MainActor [weak context = self.context] in
+ await previousSend?.value
+
+ let event: NostrEvent
do {
- let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
- let suffix = String(identity.publicKeyHex.suffix(4))
- displaySender = context.nickname + "#" + suffix
- localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
-
- let teleported = context.isTeleported
- let event = try NostrProtocol.createEphemeralGeohashEvent(
+ event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
- nickname: context.nickname,
- teleported: teleported
- )
-
- messageID = event.id
- messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
- geoContext = (
- channel: channel,
- event: event,
- identity: identity,
+ nickname: nickname,
teleported: teleported
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
- context.addSystemMessage(
- String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
- )
- return nil
- }
- }
-
- let message = BitchatMessage(
- id: messageID,
- sender: displaySender,
- content: trimmed,
- timestamp: messageTimestamp,
- isRelay: false,
- senderPeerID: localSenderPeerID,
- mentions: mentions.isEmpty ? nil : mentions
- )
-
- return (message, geoContext)
- }
-
- func appendLocalEcho(_ message: BitchatMessage) {
- context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
-
- let contentKey = context.normalizedContentKey(message.content)
- context.recordContentKey(contentKey, timestamp: message.timestamp)
- }
-
- func routePublicMessage(
- originalContent: String,
- mentions: [String],
- geoContext: ChatViewModel.GeoOutgoingContext?,
- messageID: String,
- timestamp: Date
- ) {
- switch context.activeChannel {
- case .mesh:
- context.recordPublicActivity(forChannelKey: "mesh")
- context.sendMeshMessage(
- originalContent,
- mentions: mentions,
- messageID: messageID,
- timestamp: timestamp
- )
-
- case .location(let channel):
- context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
-
- guard let geoContext, geoContext.channel.geohash == channel.geohash else {
- SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
- context.addSystemMessage(
+ context?.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
+ guard let context else { return }
- Task { @MainActor [weak context = self.context] in
- context?.sendGeohash(context: geoContext)
- }
+ let message = BitchatMessage(
+ id: event.id,
+ sender: displaySender,
+ content: trimmed,
+ timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
+ isRelay: false,
+ senderPeerID: senderPeerID,
+ mentions: mentions.isEmpty ? nil : mentions
+ )
+
+ context.appendPublicMessage(message, to: ConversationID(channelID: .location(channel)))
+ let contentKey = context.normalizedContentKey(message.content)
+ context.recordContentKey(contentKey, timestamp: message.timestamp)
+
+ context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
+ context.sendGeohash(context: (
+ channel: channel,
+ event: event,
+ identity: identity,
+ teleported: teleported
+ ))
}
}
+
+ func appendLocalEcho(_ message: BitchatMessage, to conversationID: ConversationID) {
+ context.appendPublicMessage(message, to: conversationID)
+
+ let contentKey = context.normalizedContentKey(message.content)
+ context.recordContentKey(contentKey, timestamp: message.timestamp)
+ }
}
diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift
index e97f2214..b3c0a199 100644
--- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift
+++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift
@@ -323,6 +323,15 @@ final class ChatPeerIdentityCoordinator {
func startPrivateChat(with peerID: PeerID, suppressSystemMessages: Bool = false) {
guard peerID != context.myPeerID else { return }
+ // Group chats are virtual conversations: no peer identity, favorites,
+ // handshake, or message consolidation applies — just select the chat.
+ if peerID.isGroup {
+ context.selectedPrivateChatFingerprint = nil
+ context.beginPrivateChatSession(with: peerID)
+ context.markPrivateChatRead(peerID)
+ return
+ }
+
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if context.unifiedIsBlocked(peerID) {
diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift
index d6777a71..c5f19c44 100644
--- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift
+++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift
@@ -82,7 +82,9 @@ protocol ChatPublicConversationContext: AnyObject {
// MARK: Inbound public message processing
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
func isMessageBlocked(_ message: BitchatMessage) -> Bool
- func allowPublicMessage(senderKey: String, contentKey: String) -> Bool
+ /// `powBits` is the validated NIP-13 difficulty of the source Nostr event
+ /// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket.
+ func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
/// flush, which commits it to `conversationID` in the store.
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
@@ -137,8 +139,8 @@ extension ChatViewModel: ChatPublicConversationContext {
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
}
- func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
- publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
+ func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
+ publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
}
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
@@ -290,7 +292,17 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func clearCurrentPublicTimeline() {
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
+ // The SPM test process shares the real Application Support tree, so this
+ // detached deletion can land mid-test under parallel scheduling and flake
+ // a file-dependent test. Tests never need the on-disk media cleared.
+ guard !TestEnvironment.isRunningTests else { return }
+
Task.detached(priority: .utility) {
+ // Skipped under tests: the test process shares the user's real
+ // ~/Library/Application Support/files tree, and this detached
+ // wipe fires at a nondeterministic time — racing tests that
+ // write media there (see the same guard in panicClearAllData).
+ guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
@@ -367,7 +379,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
guard let context else { return }
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
- let event = try NostrProtocol.createEphemeralGeohashEvent(
+ let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: content,
geohash: channel.geohash,
senderIdentity: identity,
@@ -395,7 +407,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
)
}
- func handlePublicMessage(_ message: BitchatMessage) {
+ /// - Parameter powBits: validated NIP-13 difficulty of the source Nostr
+ /// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
+ /// rate limit; low/no-PoW events keep the strict limits so old clients
+ /// still get through at normal rates.
+ func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
let finalMessage = context.processActionMessage(message)
if context.isMessageBlocked(finalMessage) { return }
@@ -405,7 +421,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = context.normalizedContentKey(finalMessage.content)
- if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
+ if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
return
}
}
diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift
index 31c38f7b..34b1653c 100644
--- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift
+++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Routing & acknowledgements
func flushRouterOutbox(for peerID: PeerID)
+ /// Offer queued mail for *other* peers to this newly connected courier.
+ func retryCourierDeposits(via peerID: PeerID)
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status
@@ -69,6 +71,11 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
+
+ // MARK: Group payloads (creator-signed state over Noise)
+ func handleGroupInvitePayload(from peerID: PeerID, payload: Data)
+ func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data)
+ func handleVouchPayload(from peerID: PeerID, payload: Data)
}
extension ChatViewModel: ChatTransportEventContext {
@@ -103,6 +110,10 @@ extension ChatViewModel: ChatTransportEventContext {
messageRouter.flushOutbox(for: peerID)
}
+ func retryCourierDeposits(via peerID: PeerID) {
+ messageRouter.courierBecameAvailable(peerID)
+ }
+
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshService.sendDeliveryAck(for: messageID, to: peerID)
}
@@ -123,6 +134,18 @@ extension ChatViewModel: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
}
+
+ func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
+ groupCoordinator.handleGroupInvitePayload(from: peerID, payload: payload)
+ }
+
+ func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
+ groupCoordinator.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
+ }
+
+ func handleVouchPayload(from peerID: PeerID, payload: Data) {
+ vouchCoordinator.handleVouchPayload(from: peerID, payload: payload)
+ }
}
final class ChatTransportEventCoordinator {
@@ -208,6 +231,7 @@ final class ChatTransportEventCoordinator {
}
context.flushRouterOutbox(for: peerID)
+ context.retryCourierDeposits(via: peerID)
}
}
@@ -364,6 +388,15 @@ private extension ChatTransportEventCoordinator {
case .verifyResponse:
context.handleVerifyResponsePayload(from: peerID, payload: payload)
+
+ case .groupInvite:
+ context.handleGroupInvitePayload(from: peerID, payload: payload)
+
+ case .groupKeyUpdate:
+ context.handleGroupKeyUpdatePayload(from: peerID, payload: payload)
+
+ case .vouch:
+ context.handleVouchPayload(from: peerID, payload: payload)
}
}
diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift
index e82ebd35..8f7d484a 100644
--- a/bitchat/ViewModels/ChatVerificationCoordinator.swift
+++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift
@@ -24,6 +24,10 @@ protocol ChatVerificationContext: AnyObject {
func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState()
+ /// After a fingerprint becomes verified, run a transitive-vouch pass over
+ /// currently connected peers (so verifying a peer you're already connected
+ /// to sends vouches immediately, and the new identity propagates onward).
+ func vouchToConnectedVerifiedPeers()
// MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
@@ -86,6 +90,10 @@ extension ChatViewModel: ChatVerificationContext {
peerIdentityStore.setVerified(fingerprint, verified: verified)
}
+ func vouchToConnectedVerifiedPeers() {
+ vouchCoordinator.vouchToConnectedVerifiedPeers()
+ }
+
var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers
}
@@ -148,6 +156,9 @@ final class ChatVerificationCoordinator {
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID)
+ // Verifying a peer is a vouch trigger: push attestations to my other
+ // connected verified peers (and to this one if already connected).
+ context.vouchToConnectedVerifiedPeers()
}
func unverifyFingerprint(for peerID: PeerID) {
@@ -340,6 +351,8 @@ final class ChatVerificationCoordinator {
}
context.updateEncryptionStatus(for: peerID)
+ // QR verification just completed — same vouch trigger as manual verify.
+ context.vouchToConnectedVerifiedPeers()
}
}
diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift
index 541509b2..0dd2a5fa 100644
--- a/bitchat/ViewModels/ChatViewModel.swift
+++ b/bitchat/ViewModels/ChatViewModel.swift
@@ -102,7 +102,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
var canSendMediaInCurrentContext: Bool {
if let peer = selectedPrivateChatPeer {
- return !(peer.isGeoDM || peer.isGeoChat)
+ // Media transfer is not wired for groups in v1 (sendFilePrivate
+ // rejects the virtual group_ recipient), so keep the affordance off.
+ return !(peer.isGeoDM || peer.isGeoChat || peer.isGroup)
}
switch activeChannel {
case .mesh: return true
@@ -177,6 +179,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
+ lazy var groupCoordinator = ChatGroupCoordinator(context: self)
+ lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
// Computed properties for compatibility
@MainActor
@@ -305,12 +309,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
var nostrRelayManager: NostrRelayManager?
private let userDefaults = UserDefaults.standard
let keychain: KeychainManagerProtocol
+ /// Private group membership: keys in the keychain, metadata on disk.
+ let groupStore: GroupStore
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID {
get { conversations.activeChannel }
set {
guard conversations.activeChannel != newValue else { return }
+ // Leaving a channel expedites any in-flight NIP-13 mining: the
+ // pending message still sends, at the difficulty already reached.
+ outgoingCoordinator.expeditePendingGeohashMining()
conversations.setActiveChannel(newValue)
visibleMessagesCache = nil
objectWillChange.send()
@@ -764,15 +773,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
+ let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
+ meshService.sfMetrics = .shared
self.init(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
- transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
+ transport: meshService,
conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
- locationManager: locationManager
+ locationManager: locationManager,
+ outboxStore: MessageOutboxStore(keychain: keychain),
+ sfMetrics: .shared
)
}
@@ -788,7 +801,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared,
- readReceiptsDefaults: UserDefaults? = nil
+ readReceiptsDefaults: UserDefaults? = nil,
+ outboxStore: MessageOutboxStore? = nil,
+ sfMetrics: StoreAndForwardMetrics? = nil
) {
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -797,10 +812,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
- meshService: transport
+ meshService: transport,
+ outboxStore: outboxStore,
+ sfMetrics: sfMetrics
)
self.keychain = keychain
+ self.groupStore = GroupStore(keychain: keychain)
self.idBridge = idBridge
self.identityManager = identityManager
self.conversations = conversations
@@ -1209,8 +1227,23 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
- // Drop courier mail carried for third parties (memory and disk)
+ // Drop courier mail carried for third parties (memory and disk),
+ // our own queued outbox, the carried public history, and the
+ // counters describing all of it
CourierStore.shared.wipe()
+ messageRouter.wipeOutbox()
+ GossipMessageArchive.wipeDefault()
+ StoreAndForwardMetrics.shared.reset()
+
+ // Drop private group keys and rosters (keychain + disk)
+ groupStore.wipe()
+ // Drop cached peers' prekey bundles (who we could write to is
+ // metadata too). Our own prekey privates are keychain-backed and go
+ // with deleteAllKeychainData above plus the identity reset below.
+ PrekeyBundleStore.shared.wipe()
+ // Drop bulletin-board posts and tombstones (memory and disk); board
+ // posts are signed with our identity key and persist for days.
+ BoardStore.shared.wipe()
// Identity manager has cleared persisted identity data above
@@ -1280,6 +1313,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Delete ALL media files (incoming and outgoing) in background
Task.detached(priority: .utility) {
+ // Skipped under tests: the test process shares the user's real
+ // ~/Library/Application Support/files tree, and this detached
+ // utility-priority wipe fires at a nondeterministic time —
+ // deleting media that concurrently running tests (e.g. the
+ // sendImage flow) just wrote there, and the developer's real
+ // app data with it.
+ guard !TestEnvironment.isRunningTests else { return }
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
@@ -1499,6 +1539,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func setupNoiseCallbacks() {
verificationCoordinator.setupNoiseCallbacks()
+ vouchCoordinator.setupNoiseCallbacks()
+ }
+
+ /// Whether the fingerprint currently counts as vouched (≥1 valid vouch
+ /// from a voucher I verified, and no explicit verification of mine).
+ @MainActor
+ func isVouchedFingerprint(_ fingerprint: String) -> Bool {
+ identityManager.isVouched(fingerprint: fingerprint)
}
// MARK: - BitchatDelegate Methods
@@ -1538,6 +1586,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
}
+ /// Origin conversation for deferred command output, captured when the
+ /// command is issued (before any async work starts).
+ @MainActor
+ func currentCommandDestination() -> CommandOutputDestination {
+ if let peerID = selectedPrivateChatPeer {
+ return .privateChat(peerID)
+ }
+ // Deferring commands (/ping) are rejected in geohash channels, so a
+ // non-DM origin is always the #mesh timeline.
+ return .meshTimeline
+ }
+
+ /// Routes deferred command output (async /ping results) into the
+ /// conversation captured at issue time, immune to chat switches in the
+ /// meantime. A DM result lands in the origin chat's history even if that
+ /// chat is no longer selected (or was cleared — it then reappears as the
+ /// first message when the chat is reopened).
+ @MainActor
+ func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
+ switch destination {
+ case .privateChat(let peerID):
+ addLocalPrivateSystemMessage(content, to: peerID)
+ case .meshTimeline:
+ publicConversationCoordinator.addMeshOnlySystemMessage(content)
+ }
+ }
+
// MARK: - Message Reception
@MainActor
@@ -1569,6 +1644,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
)
}
+ func didReceiveGroupMessage(payload: Data, timestamp: Date) {
+ Task { @MainActor [weak self] in
+ self?.groupCoordinator.handleGroupMessagePayload(payload, timestamp: timestamp)
+ }
+ }
+
// MARK: - QR Verification API
@MainActor
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
@@ -1596,6 +1677,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func didUpdatePeerList(_ peers: [PeerID]) {
peerListCoordinator.didUpdatePeerList(peers)
+ // A peer-list update follows every verified announce, which is where a
+ // peer's `.vouch` capability actually arrives — retry vouching now that
+ // capabilities may finally be known (closes the auth-time capability race).
+ Task { @MainActor [weak self] in
+ self?.vouchCoordinator.peersUpdated(peers)
+ }
}
@MainActor
@@ -1684,6 +1771,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func addGeohashOnlySystemMessage(_ content: String) {
publicConversationCoordinator.addGeohashOnlySystemMessage(content)
}
+
+ /// Add a local system message to one specific geohash timeline, active or
+ /// not. Used by the board's new-pin alerts to scope-match the pin's channel.
+ @MainActor
+ func addGeohashSystemMessage(_ content: String, geohash: String) {
+ let systemMessage = BitchatMessage(
+ sender: "system",
+ content: content,
+ timestamp: Date(),
+ isRelay: false
+ )
+ appendGeohashMessageIfAbsent(systemMessage, toGeohash: geohash)
+ }
// Send a public message without adding a local user echo.
// Used for emotes where we want a local system-style confirmation instead.
@MainActor
@@ -1691,12 +1791,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.sendPublicRaw(content)
}
+ // Send a normal public message (with local echo) to the active channel.
+ // CommandContextProvider hook for commands that post real messages
+ // (`/pay`); only called when no private chat is selected.
+ @MainActor
+ func sendPublicMessage(_ content: String) {
+ sendMessage(content)
+ }
+
/// Handle incoming public message
@MainActor
func handlePublicMessage(_ message: BitchatMessage) {
publicConversationCoordinator.handlePublicMessage(message)
}
+ /// Handle an incoming public Nostr message with its validated NIP-13
+ /// difficulty; sufficient PoW relaxes the per-sender rate limit.
+ @MainActor
+ func handlePublicMessage(_ message: BitchatMessage, powBits: Int) {
+ publicConversationCoordinator.handlePublicMessage(message, powBits: powBits)
+ }
+
/// Check for mentions and send notifications
func checkForMentions(_ message: BitchatMessage) {
publicConversationCoordinator.checkForMentions(message)
diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift
index 8897d7c8..caca90ec 100644
--- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift
+++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
- meshService: Transport
+ meshService: Transport,
+ outboxStore: MessageOutboxStore? = nil,
+ sfMetrics: StoreAndForwardMetrics? = nil
) {
let commandProcessor = CommandProcessor(identityManager: identityManager)
let privateChatManager = PrivateChatManager(meshService: meshService)
@@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle {
)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID
- let messageRouter = MessageRouter(transports: [meshService, nostrTransport])
+ let messageRouter = MessageRouter(
+ transports: [meshService, nostrTransport],
+ outboxStore: outboxStore,
+ metrics: sfMetrics
+ )
self.commandProcessor = commandProcessor
self.messageRouter = messageRouter
@@ -66,6 +72,7 @@ final class ChatViewModelBootstrapper {
configureNoiseCallbacks()
bindTransferProgress()
configureGeoChannels()
+ configureGateway()
bindTeleportState()
requestNotifications()
registerObservers()
@@ -238,6 +245,72 @@ private extension ChatViewModelBootstrapper {
)
}
+ /// Wires the gateway-mode policy layer (`GatewayService`) to the mesh
+ /// transport, the relay manager, and the inbound Nostr pipeline. All
+ /// dependencies are closures so the service stays unit-testable with
+ /// fakes.
+ func configureGateway() {
+ // Gateway mode bridges BLE mesh <-> Nostr; a mock transport (tests)
+ // has no carrier packets to bridge.
+ guard let bleService = viewModel.meshService as? BLEService else { return }
+ let gateway = GatewayService.shared
+
+ gateway.publishToRelays = { event, geohash in
+ let relays = GeoRelayDirectory.shared.closestRelays(
+ toGeohash: geohash,
+ count: TransportConfig.nostrGeoRelayCount
+ )
+ // Symmetric with the local send path (GeohashSubscriptionManager
+ // .sendGeohash): with no known geo relay, refuse rather than
+ // publish to default relays no geo subscriber reads — that would
+ // be silent dead traffic, not delivery.
+ guard !relays.isEmpty else {
+ SecureLogger.warning("🌐 Gateway: no geo relays for #\(geohash); not publishing carried event", category: .session)
+ return
+ }
+ NostrRelayManager.shared.sendEvent(event, to: relays)
+ }
+ gateway.broadcastToMesh = { [weak bleService] payload in
+ bleService?.broadcastNostrCarrier(payload)
+ }
+ gateway.sendToGatewayPeer = { [weak bleService] payload, peer in
+ bleService?.sendNostrCarrier(payload, to: peer) ?? false
+ }
+ gateway.availableGatewayPeers = { [weak bleService] in
+ bleService?.reachableGatewayPeers() ?? []
+ }
+ gateway.relaysConnected = { NostrRelayManager.shared.isConnected }
+ gateway.currentGeohash = { [weak viewModel] in viewModel?.currentGeohash }
+ // Carried events enter the same pipeline as relay-received events so
+ // blocking, rate limits, dedup, and rendering behave identically.
+ gateway.injectInbound = { [weak viewModel] event in
+ viewModel?.handleNostrEvent(event)
+ }
+ // The capability bit is advertised ONLY while the toggle is on; a
+ // change forces a re-announce so peers learn promptly.
+ gateway.onEnabledChanged = { [weak bleService] enabled in
+ bleService?.setLocalCapability(.gateway, enabled: enabled)
+ }
+ bleService.onNostrCarrierPacket = { payload, from, directedToUs in
+ GatewayService.shared.handleMeshCarrier(payload, from: from, directedToUs: directedToUs)
+ }
+
+ // Uplinks deposited while relays were unreachable flush on reconnect.
+ NostrRelayManager.shared.$isConnected
+ .receive(on: DispatchQueue.main)
+ .sink { connected in
+ if connected {
+ GatewayService.shared.flushQueuedUplinks()
+ }
+ }
+ .store(in: &viewModel.cancellables)
+
+ // Apply the persisted toggle at launch.
+ if gateway.isEnabled {
+ bleService.setLocalCapability(.gateway, enabled: true)
+ }
+ }
+
func bindTeleportState() {
viewModel.locationManager.$teleported
.receive(on: DispatchQueue.main)
diff --git a/bitchat/ViewModels/ChatVouchCoordinator.swift b/bitchat/ViewModels/ChatVouchCoordinator.swift
new file mode 100644
index 00000000..26cb8158
--- /dev/null
+++ b/bitchat/ViewModels/ChatVouchCoordinator.swift
@@ -0,0 +1,272 @@
+import BitFoundation
+import BitLogger
+import Foundation
+
+/// The narrow surface `ChatVouchCoordinator` needs from its owner.
+///
+/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
+/// minimal context it actually uses instead of holding an `unowned` back-ref
+/// to the whole `ChatViewModel`. This keeps the coordinator independently
+/// testable (see `ChatVouchCoordinatorContextTests`) and makes its true
+/// dependencies explicit.
+@MainActor
+protocol ChatVouchContext: AnyObject {
+ // MARK: Identity & trust state
+ func getFingerprint(for peerID: PeerID) -> String?
+ func isVerifiedFingerprint(_ fingerprint: String) -> Bool
+ /// The peer's announce-bound Ed25519 signing key, if known this session.
+ func signingKey(forFingerprint fingerprint: String) -> Data?
+ /// Verified fingerprints ordered most recently verified first.
+ func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
+ /// Stores an accepted vouch (identity manager enforces the storage gates).
+ @discardableResult
+ func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
+ func lastVouchBatchSent(to fingerprint: String) -> Date?
+ func markVouchBatchSent(to fingerprint: String, at date: Date)
+
+ // MARK: Transport
+ func peerCapabilities(for peerID: PeerID) -> PeerCapabilities
+ /// PeerIDs with a currently established mesh session (used to run a vouch
+ /// pass over peers we are already connected to when we verify someone).
+ func connectedPeerIDs() -> [PeerID]
+ /// Appends a session-established observer (additive; never displaces the
+ /// verification coordinator's callbacks).
+ func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
+ /// Signs `data` with our Noise (Ed25519) signing key.
+ func noiseSignData(_ data: Data) -> Data?
+ func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
+
+ // MARK: UI refresh
+ /// Signals that derived trust state changed so peer list / fingerprint
+ /// views recompute badges.
+ func notifyPeerTrustChanged()
+}
+
+extension ChatViewModel: ChatVouchContext {
+ // `getFingerprint(for:)` and `isVerifiedFingerprint(_:)` are shared
+ // requirements with the verification context and satisfied by existing
+ // `ChatViewModel` members. The members below flatten nested service
+ // accesses into intent-named calls.
+
+ func signingKey(forFingerprint fingerprint: String) -> Data? {
+ identityManager.signingPublicKey(forFingerprint: fingerprint)
+ }
+
+ func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
+ identityManager.mostRecentlyVerifiedFingerprints(limit: limit, excluding: fingerprint)
+ }
+
+ @discardableResult
+ func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
+ identityManager.recordVouch(
+ voucheeFingerprint: voucheeFingerprint,
+ voucherFingerprint: voucherFingerprint,
+ timestamp: timestamp
+ )
+ }
+
+ func lastVouchBatchSent(to fingerprint: String) -> Date? {
+ identityManager.lastVouchBatchSent(to: fingerprint)
+ }
+
+ func markVouchBatchSent(to fingerprint: String, at date: Date) {
+ identityManager.markVouchBatchSent(to: fingerprint, at: date)
+ }
+
+ func peerCapabilities(for peerID: PeerID) -> PeerCapabilities {
+ meshService.peerCapabilities(peerID)
+ }
+
+ func connectedPeerIDs() -> [PeerID] {
+ Array(connectedPeers)
+ }
+
+ func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
+ meshService.addPeerAuthenticatedObserver(handler)
+ }
+
+ func noiseSignData(_ data: Data) -> Data? {
+ meshService.noiseSignData(data)
+ }
+
+ func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
+ meshService.sendVouchAttestations(payload, to: peerID)
+ }
+
+ func notifyPeerTrustChanged() {
+ // PeerListModel refreshes on this notification; the view-model change
+ // covers FingerprintView / VerificationModel consumers.
+ NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
+ notifyUIChanged()
+ }
+}
+
+/// Transitive verification ("vouching"): when a Noise session comes up with a
+/// peer I verified, I attest — over that authenticated, encrypted session —
+/// to the other identities I have verified. Receivers accept such vouches
+/// only from peers *they* verified, giving a serverless
+/// verified-by-people-you-verified tier (`TrustLevel.vouched`).
+@MainActor
+final class ChatVouchCoordinator {
+ /// Minimum spacing between vouch batches to the same peer (persisted).
+ static let batchInterval: TimeInterval = 24 * 60 * 60
+
+ private unowned let context: any ChatVouchContext
+
+ init(context: any ChatVouchContext) {
+ self.context = context
+ }
+
+ /// Registers the session-established hook. Additive alongside the
+ /// verification coordinator's callbacks; call once at bootstrap.
+ func setupNoiseCallbacks() {
+ context.addPeerAuthenticatedObserver { [weak self] peerID, fingerprint in
+ DispatchQueue.main.async { [weak self] in
+ self?.peerAuthenticated(peerID, fingerprint: fingerprint)
+ }
+ }
+ }
+
+ /// Trigger — session established: on a Noise session coming up with a peer
+ /// I verified, attempt to send a vouch batch. Kept as the historical entry
+ /// point; the real work lives in `attemptVouch`.
+ func peerAuthenticated(_ peerID: PeerID, fingerprint: String, now: Date = Date()) {
+ attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
+ }
+
+ /// Trigger — verified announce processed: a peer's `.vouch` capability
+ /// arrives on its *announce*, which is handled independently of the Noise
+ /// handshake. This is invoked on every peer-list update (fired after each
+ /// verified announce), so it closes the capability race — the batch that
+ /// `peerAuthenticated` couldn't send (capabilities not yet known) goes out
+ /// once the capability-bearing announce lands. Throttled per peer.
+ func peersUpdated(_ peerIDs: [PeerID], now: Date = Date()) {
+ for peerID in peerIDs {
+ guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
+ attemptVouch(to: peerID, fingerprint: fingerprint, now: now)
+ }
+ }
+
+ /// Trigger — local verification completed: the user just verified a peer.
+ /// Run a vouch pass over every currently connected peer I verified. This
+ /// makes vouching fire when verifying someone already connected (whose
+ /// session is authenticated, so `peerAuthenticated` never re-fires), and it
+ /// propagates the newly-verified identity to my other verified peers.
+ /// Throttled per peer by `batchInterval`, so it can't spam.
+ func vouchToConnectedVerifiedPeers(now: Date = Date()) {
+ var sentCount = 0
+ for peerID in context.connectedPeerIDs() {
+ guard let fingerprint = context.getFingerprint(for: peerID) else { continue }
+ if attemptVouch(to: peerID, fingerprint: fingerprint, now: now) {
+ sentCount += 1
+ }
+ }
+ if sentCount > 0 {
+ SecureLogger.info(
+ "🪪 verify-triggered vouch pass sent to \(sentCount) connected peer(s)",
+ category: .security
+ )
+ }
+ }
+
+ /// Exchange policy shared by every trigger: to a peer I verified, send
+ /// attestations for up to `VouchAttestation.maxBatchCount` *other* verified
+ /// fingerprints (most recently verified first), at most once per peer per
+ /// `batchInterval`. Returns whether a batch was actually sent.
+ @discardableResult
+ func attemptVouch(to peerID: PeerID, fingerprint: String, now: Date = Date()) -> Bool {
+ guard context.isVerifiedFingerprint(fingerprint) else { return false }
+
+ // Capability gate, race-tolerant: a peer's `.vouch` bit is carried on
+ // its announce, processed independently of the Noise handshake, so at
+ // authentication time the capability set is frequently still empty.
+ // Treat an empty/unknown set as eligible — the payload is a Noise
+ // `0x12` (`NoisePayloadType.vouch`) that non-supporting peers harmlessly
+ // ignore, so sending on an unknown set is safe and avoids the race
+ // dropping the batch. Only skip when the peer advertised a non-empty
+ // capability set that explicitly lacks `.vouch`.
+ let capabilities = context.peerCapabilities(for: peerID)
+ if !capabilities.isEmpty, !capabilities.contains(.vouch) { return false }
+
+ if let lastSent = context.lastVouchBatchSent(to: fingerprint),
+ now.timeIntervalSince(lastSent) < Self.batchInterval {
+ return false
+ }
+
+ let candidates = context.recentlyVerifiedFingerprints(
+ limit: VouchAttestation.maxBatchCount,
+ excluding: fingerprint
+ )
+ var attestations: [VouchAttestation] = []
+ for candidate in candidates {
+ // Only fingerprints whose announce-bound signing key we know can
+ // be anchored to a concrete identity; skip the rest.
+ guard let fingerprintData = Data(hexString: candidate),
+ fingerprintData.count == VouchAttestation.fingerprintSize,
+ let signingKey = context.signingKey(forFingerprint: candidate),
+ signingKey.count == VouchAttestation.signingKeySize,
+ let attestation = VouchAttestation.build(
+ voucheeFingerprint: fingerprintData,
+ voucheeSigningKey: signingKey,
+ timestampMs: UInt64(now.timeIntervalSince1970 * 1000),
+ sign: context.noiseSignData
+ ) else {
+ continue
+ }
+ attestations.append(attestation)
+ }
+
+ guard !attestations.isEmpty,
+ let payload = VouchAttestation.encodeList(attestations) else { return false }
+ context.sendVouchAttestations(payload, to: peerID)
+ context.markVouchBatchSent(to: fingerprint, at: now)
+ SecureLogger.debug(
+ "🪪 Sent \(attestations.count) vouch attestation(s) to \(peerID.id.prefix(8))…",
+ category: .security
+ )
+ return true
+ }
+
+ /// Accept policy: process inbound vouches only from a sender I verified,
+ /// only with a valid Ed25519 signature under the sender's announce-bound
+ /// signing key, and only within the validity window. Self-vouches and
+ /// vouches for already-verified peers are dropped by the identity
+ /// manager's storage gates.
+ func handleVouchPayload(from peerID: PeerID, payload: Data, now: Date = Date()) {
+ guard let senderFingerprint = context.getFingerprint(for: peerID),
+ context.isVerifiedFingerprint(senderFingerprint) else {
+ SecureLogger.debug(
+ "🪪 Ignoring vouch payload from unverified peer \(peerID.id.prefix(8))…",
+ category: .security
+ )
+ return
+ }
+ guard let senderSigningKey = context.signingKey(forFingerprint: senderFingerprint) else {
+ SecureLogger.debug(
+ "🪪 No signing key for vouching peer \(peerID.id.prefix(8))…; dropping batch",
+ category: .security
+ )
+ return
+ }
+
+ var acceptedCount = 0
+ for attestation in VouchAttestation.decodeList(from: payload) {
+ guard attestation.verifySignature(voucherSigningKey: senderSigningKey),
+ !attestation.isExpired(now: now) else { continue }
+ let stored = context.recordVouch(
+ voucheeFingerprint: attestation.voucheeFingerprintHex,
+ voucherFingerprint: senderFingerprint,
+ timestamp: attestation.timestamp
+ )
+ if stored { acceptedCount += 1 }
+ }
+
+ if acceptedCount > 0 {
+ SecureLogger.info(
+ "🪪 Accepted \(acceptedCount) vouch(es) from \(senderFingerprint.prefix(8))…",
+ category: .security
+ )
+ context.notifyPeerTrustChanged()
+ }
+ }
+}
diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift
index af8d06aa..939adf6a 100644
--- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift
+++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift
@@ -13,6 +13,12 @@ extension ChatViewModel {
@MainActor
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
+ // Group chats reuse the private-chat surface but broadcast a sealed
+ // envelope instead of routing to a single peer.
+ if peerID.isGroup {
+ groupCoordinator.sendGroupMessage(content, to: peerID)
+ return
+ }
privateConversationCoordinator.sendPrivateMessage(content, to: peerID)
}
diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift
index 4e267c59..ce4c9448 100644
--- a/bitchat/ViewModels/GeohashSubscriptionManager.swift
+++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift
@@ -115,6 +115,9 @@ final class GeohashSubscriptionManager {
private weak var context: (any GeohashSubscriptionContext)?
private let inbound: NostrInboundPipeline
private let presence: GeoPresenceTracker
+ /// Geohashes already told "sent via mesh gateway" this session, so the
+ /// notice appears once per channel instead of once per message.
+ private var gatewayNoticeGeohashes = Set()
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
self.context = context
@@ -145,6 +148,9 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.subscribeNostrEvent(event)
+ // Gateway downlink: rebroadcast relay events for the viewed
+ // channel onto the mesh (no-op unless gateway mode is on).
+ GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
}
}
@@ -235,6 +241,9 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrEvent(event)
+ // Gateway downlink: rebroadcast relay events for the viewed
+ // channel onto the mesh (no-op unless gateway mode is on).
+ GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
}
}
@@ -280,6 +289,23 @@ final class GeohashSubscriptionManager {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
+ // Mesh gateway uplink: with no working relay connection, hand the
+ // locally signed event to a mesh peer advertising the gateway
+ // capability (keys never leave this device — only the finished,
+ // signed event travels). Uplink is only ever attempted here, for a
+ // freshly composed event, never for received carrier events (loop
+ // rule 3 in GatewayService).
+ if GatewayService.shared.uplinkViaMesh(event: event, geohash: channel.geohash),
+ gatewayNoticeGeohashes.insert(channel.geohash).inserted {
+ context.addPublicSystemMessage(
+ String(
+ localized: "system.gateway.sent_via_mesh",
+ defaultValue: "sent via mesh gateway",
+ comment: "System message when a geohash message was handed to a mesh internet gateway because no relay is reachable"
+ )
+ )
+ }
+
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug(
diff --git a/bitchat/ViewModels/MessageRateLimiter.swift b/bitchat/ViewModels/MessageRateLimiter.swift
index 5bca9a6d..a0309447 100644
--- a/bitchat/ViewModels/MessageRateLimiter.swift
+++ b/bitchat/ViewModels/MessageRateLimiter.swift
@@ -48,15 +48,25 @@ struct MessageRateLimiter {
self.contentRefill = contentRefillPerSec
}
- mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
- var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
- capacity: senderCapacity,
- tokens: senderCapacity,
- refillPerSec: senderRefill,
- lastRefill: now
- )
- let senderAllowed = senderBucket.allow(now: now)
- senderBuckets[senderKey] = senderBucket
+ /// - Parameter powBits: validated NIP-13 difficulty of the event
+ /// (`NostrPoW.validatedDifficulty`; 0 for mesh or no-PoW events).
+ /// At or above `NostrPoW.rateLimitBypassBits` the per-sender bucket is
+ /// skipped entirely — each such message paid for itself with work — but
+ /// the per-content flood bucket still applies.
+ mutating func allow(senderKey: String, contentKey: String, powBits: Int = 0, now: Date = Date()) -> Bool {
+ let senderAllowed: Bool
+ if powBits >= NostrPoW.rateLimitBypassBits {
+ senderAllowed = true
+ } else {
+ var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
+ capacity: senderCapacity,
+ tokens: senderCapacity,
+ refillPerSec: senderRefill,
+ lastRefill: now
+ )
+ senderAllowed = senderBucket.allow(now: now)
+ senderBuckets[senderKey] = senderBucket
+ }
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift
index 337fa474..be50a687 100644
--- a/bitchat/ViewModels/NostrInboundPipeline.swift
+++ b/bitchat/ViewModels/NostrInboundPipeline.swift
@@ -32,7 +32,10 @@ protocol NostrInboundPipelineContext: AnyObject {
func recordGeoParticipant(pubkeyHex: String)
// MARK: Inbound public messages
- func handlePublicMessage(_ message: BitchatMessage)
+ /// `powBits` is the validated NIP-13 difficulty of the source event
+ /// (`NostrPoW.validatedDifficulty`); it relaxes the per-sender rate limit
+ /// downstream.
+ func handlePublicMessage(_ message: BitchatMessage, powBits: Int)
func checkForMentions(_ message: BitchatMessage)
func sendHapticFeedback(for message: BitchatMessage)
func parseMentions(from content: String) -> [String]
@@ -152,6 +155,7 @@ final class NostrInboundPipeline {
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = context.parseMentions(from: content)
+ let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
let message = BitchatMessage(
id: event.id,
sender: senderName,
@@ -165,7 +169,7 @@ final class NostrInboundPipeline {
Task { @MainActor [weak context] in
guard let context else { return }
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
- context.handlePublicMessage(message)
+ context.handlePublicMessage(message, powBits: powBits)
if !isBlocked {
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
@@ -187,10 +191,12 @@ final class NostrInboundPipeline {
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id)
+ let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
+
// 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))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
+ SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
@@ -255,7 +261,7 @@ final class NostrInboundPipeline {
Task { @MainActor [weak context] in
guard let context else { return }
- context.handlePublicMessage(message)
+ context.handlePublicMessage(message, powBits: powBits)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
@@ -297,7 +303,9 @@ final class NostrInboundPipeline {
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
- case .verifyChallenge, .verifyResponse:
+ // Group state travels only over mesh Noise sessions in v1; anything
+ // claiming to be group traffic over Nostr is ignored.
+ case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
break
}
}
@@ -349,7 +357,9 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
- case .verifyChallenge, .verifyResponse:
+ // Group state travels only over mesh Noise sessions in v1; anything
+ // claiming to be group traffic over Nostr is ignored.
+ case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
break
}
}
@@ -428,7 +438,9 @@ final class NostrInboundPipeline {
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
- case .verifyChallenge, .verifyResponse:
+ // Group state travels only over mesh Noise sessions
+ // in v1; group traffic over Nostr is ignored.
+ case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch:
break
}
}
diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift
index 152c09df..3f74a354 100644
--- a/bitchat/Views/AppInfoView.swift
+++ b/bitchat/Views/AppInfoView.swift
@@ -5,6 +5,11 @@ struct AppInfoView: View {
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
+ /// Supplies the mesh topology map data. Nil (previews, missing wiring)
+ /// hides the topology row entirely.
+ var topologyProvider: (@MainActor () -> MeshTopologyDisplayModel)?
+ @State private var showTopology = false
+
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
}
@@ -75,6 +80,15 @@ struct AppInfoView: View {
]
}
+ enum Network {
+ static let title: LocalizedStringKey = "app_info.network.title"
+ static let topology = AppInfoFeatureInfo(
+ icon: "point.3.connected.trianglepath.dotted",
+ title: "app_info.network.topology.title",
+ description: "app_info.network.topology.description"
+ )
+ }
+
enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -129,6 +143,11 @@ struct AppInfoView: View {
.themedSheetBackground()
}
.frame(width: 600, height: 700)
+ .sheet(isPresented: $showTopology) {
+ if let topologyProvider {
+ MeshTopologyView(provider: topologyProvider)
+ }
+ }
#else
NavigationView {
ScrollView {
@@ -143,6 +162,11 @@ struct AppInfoView: View {
}
}
}
+ .sheet(isPresented: $showTopology) {
+ if let topologyProvider {
+ MeshTopologyView(provider: topologyProvider)
+ }
+ }
#endif
}
@@ -199,6 +223,27 @@ struct AppInfoView: View {
.foregroundColor(textColor)
}
+ // Network diagnostics
+ if topologyProvider != nil {
+ VStack(alignment: .leading, spacing: 16) {
+ SectionHeader(Strings.Network.title)
+
+ Button {
+ showTopology = true
+ } label: {
+ HStack(spacing: 0) {
+ FeatureRow(info: Strings.Network.topology)
+ Image(systemName: "chevron.right")
+ .font(.bitchatSystem(size: 12))
+ .foregroundColor(secondaryTextColor)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityHint(Text("app_info.network.topology.hint"))
+ }
+ }
+
// Features
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Features.title)
diff --git a/bitchat/Views/Components/PaymentChipView.swift b/bitchat/Views/Components/PaymentChipView.swift
index e01887d0..bdda8772 100644
--- a/bitchat/Views/Components/PaymentChipView.swift
+++ b/bitchat/Views/Components/PaymentChipView.swift
@@ -7,12 +7,17 @@
//
import SwiftUI
+#if os(iOS)
+import UIKit
+#else
+import AppKit
+#endif
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
@ThemedPalette private var palette
-
+
enum PaymentType {
case cashu(String)
case lightning(String)
@@ -35,14 +40,33 @@ struct PaymentChipView: View {
return URL(string: link)
}
}
-
+
+ /// The bare `cashuA…`/`cashuB…` bearer string, when this is a Cashu chip.
+ var cashuToken: String? {
+ if case .cashu(let link) = self {
+ return CashuTokenDecoder.bareToken(from: link)
+ }
+ return nil
+ }
+
+ /// Web fallback for redemption when no wallet handles `cashu:` URLs.
+ /// The token only reaches the site the user's browser loads; the app
+ /// itself never contacts a mint.
+ var cashuWebRedeemURL: URL? {
+ guard let token = cashuToken,
+ let enc = token.addingPercentEncoding(withAllowedCharacters: Self.cashuAllowedCharacters) else {
+ return nil
+ }
+ return URL(string: "https://redeem.cashu.me/?token=\(enc)")
+ }
+
var emoji: String {
switch self {
case .cashu: "🥜"
case .lightning: "⚡"
}
}
-
+
var label: String {
switch self {
case .cashu:
@@ -52,27 +76,56 @@ struct PaymentChipView: View {
}
}
}
-
+
let paymentType: PaymentType
-
+ /// Decoded once at construction; tokens are capped in size so this is
+ /// cheap, and rows re-render often enough that lazy decode in `body`
+ /// would just repeat the work.
+ private let cashuInfo: CashuTokenDecoder.TokenInfo?
+
+ init(paymentType: PaymentType) {
+ self.paymentType = paymentType
+ if case .cashu(let link) = paymentType {
+ self.cashuInfo = CashuTokenDecoder.decode(link)
+ } else {
+ self.cashuInfo = nil
+ }
+ }
+
private var fgColor: Color { palette.primary }
private var bgColor: Color {
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
}
private var border: Color { fgColor.opacity(0.25) }
-
+
+ /// "500 sat · mint.example.com", degrading to the generic label when the
+ /// token didn't decode (V4 payloads we can't walk, malformed input…).
+ private var primaryLabel: String {
+ guard let info = cashuInfo else { return paymentType.label }
+ var parts: [String] = []
+ if let amount = info.displayAmount { parts.append(amount) }
+ if let host = info.mintHost { parts.append(host) }
+ return parts.isEmpty ? paymentType.label : parts.joined(separator: " · ")
+ }
+
+ private var memoLabel: String? { cashuInfo?.memo }
+
var body: some View {
Button {
- #if os(iOS)
- if let url = paymentType.url { openURL(url) }
- #else
- if let url = paymentType.url { NSWorkspace.shared.open(url) }
- #endif
+ primaryAction()
} label: {
HStack(spacing: 6) {
Text(paymentType.emoji)
- Text(paymentType.label)
- .bitchatFont(size: 12, weight: .semibold)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(primaryLabel)
+ .bitchatFont(size: 12, weight: .semibold)
+ if let memoLabel {
+ Text(memoLabel)
+ .bitchatFont(size: 10)
+ .opacity(0.7)
+ .lineLimit(1)
+ }
+ }
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
@@ -87,13 +140,102 @@ struct PaymentChipView: View {
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
+ .contextMenu {
+ if let token = paymentType.cashuToken {
+ Button {
+ copyToPasteboard(token)
+ } label: {
+ Label(String(localized: "content.payment.copy_token", comment: "Context menu action copying a Cashu token to the pasteboard"), systemImage: "doc.on.doc")
+ }
+ Button {
+ redeemCashu()
+ } label: {
+ Label(String(localized: "content.payment.redeem_wallet", comment: "Context menu action opening a Cashu token in an ecash wallet app"), systemImage: "wallet.pass")
+ }
+ if let webURL = paymentType.cashuWebRedeemURL {
+ Button {
+ openExternalURL(webURL)
+ } label: {
+ Label(String(localized: "content.payment.redeem_web", comment: "Context menu action opening a Cashu token in the web redemption page"), systemImage: "safari")
+ }
+ }
+ }
+ }
+ .accessibilityLabel(Text(verbatim: accessibilityText))
+ }
+
+ private var accessibilityText: String {
+ var text = "\(paymentType.label): \(primaryLabel)"
+ if let memoLabel { text += ", \(memoLabel)" }
+ return text
+ }
+
+ // MARK: - Actions
+
+ private func primaryAction() {
+ switch paymentType {
+ case .cashu:
+ redeemCashu()
+ case .lightning:
+ #if os(iOS)
+ if let url = paymentType.url { openURL(url) }
+ #else
+ if let url = paymentType.url { NSWorkspace.shared.open(url) }
+ #endif
+ }
+ }
+
+ /// Redemption is delegated: try a wallet registered for `cashu:` URLs
+ /// first, then fall back to the web redemption page. Uses the platform
+ /// opener directly (not the `openURL` environment) because the message
+ /// list overrides that action for cashu/lightning schemes without a
+ /// fallback path.
+ private func redeemCashu() {
+ let walletURL = paymentType.url
+ let webURL = paymentType.cashuWebRedeemURL
+ #if os(iOS)
+ if let walletURL {
+ UIApplication.shared.open(walletURL, options: [:]) { accepted in
+ if !accepted, let webURL {
+ UIApplication.shared.open(webURL)
+ }
+ }
+ } else if let webURL {
+ UIApplication.shared.open(webURL)
+ }
+ #else
+ if let walletURL, NSWorkspace.shared.urlForApplication(toOpen: walletURL) != nil {
+ NSWorkspace.shared.open(walletURL)
+ } else if let webURL {
+ NSWorkspace.shared.open(webURL)
+ } else if let walletURL {
+ NSWorkspace.shared.open(walletURL)
+ }
+ #endif
+ }
+
+ private func openExternalURL(_ url: URL) {
+ #if os(iOS)
+ UIApplication.shared.open(url)
+ #else
+ NSWorkspace.shared.open(url)
+ #endif
+ }
+
+ private func copyToPasteboard(_ string: String) {
+ #if os(iOS)
+ UIPasteboard.general.string = string
+ #else
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(string, forType: .string)
+ #endif
}
}
#Preview {
let cashuLink = "https://example.com/cashu"
let lightningLink = "https://example.com/lightning"
-
+
List {
HStack {
PaymentChipView(paymentType: .cashu(cashuLink))
diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift
index eb188ed7..9bac1095 100644
--- a/bitchat/Views/ContentHeaderView.swift
+++ b/bitchat/Views/ContentHeaderView.swift
@@ -1,21 +1,17 @@
import SwiftUI
-#if os(iOS)
-import UIKit
-#endif
struct ContentHeaderView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
+ @EnvironmentObject private var boardAlertsModel: BoardAlertsModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool
- @Binding var showLocationNotes: Bool
- @Binding var notesGeohash: String?
var isNicknameFieldFocused: FocusState.Binding
let headerHeight: CGFloat
@@ -25,6 +21,14 @@ struct ContentHeaderView: View {
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
+ /// Unified notices sheet (board posts + location notes) for the current
+ /// channel context.
+ @State private var showNotices = false
+
+ /// Board posts mirrored from the store so the pin icon can show when the
+ /// current scope has notices.
+ @State private var boardPosts: [BoardPostPacket] = []
+
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -91,6 +95,19 @@ struct ContentHeaderView: View {
}()
HStack(spacing: 2) {
+ if locationChannelsModel.gatewayEnabled {
+ Image(systemName: "globe")
+ .font(.bitchatSystem(size: 12))
+ .foregroundColor(palette.secondary.opacity(0.8))
+ .headerTapTarget()
+ .accessibilityLabel(
+ String(localized: "content.accessibility.gateway_active", defaultValue: "Internet gateway active, sharing your connection with the mesh", comment: "Accessibility label for the internet gateway indicator")
+ )
+ .help(
+ String(localized: "content.header.gateway_active", defaultValue: "Sharing your internet connection with nearby mesh peers", comment: "Tooltip for the internet gateway indicator")
+ )
+ }
+
if carriedMailCount > 0 {
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12))
@@ -121,23 +138,41 @@ struct ContentHeaderView: View {
)
}
- if case .mesh = locationChannelsModel.selectedChannel,
- locationChannelsModel.permissionState == .authorized {
- Button(action: {
- locationChannelsModel.enableAndRefresh()
- notesGeohash = locationChannelsModel.currentBuildingGeohash
- showLocationNotes = true
- }) {
- Image(systemName: "note.text")
- .font(.bitchatSystem(size: 12))
- .foregroundColor(Color.orange.opacity(0.8))
- .headerTapTarget()
+ Button(action: {
+ var scopes: Set = [""]
+ if let geoScope = noticesGeoScope {
+ scopes.insert(geoScope)
}
- .buttonStyle(.plain)
- .accessibilityLabel(
- String(localized: "content.accessibility.location_notes", comment: "Accessibility label for location notes button")
- )
+ boardAlertsModel.markSeen(forScopes: scopes)
+ showNotices = true
+ }) {
+ // Fill marks unseen new pins; the tint says the current
+ // scope has notices at all.
+ Image(systemName: unseenNoticesCount > 0 ? "pin.fill" : "pin")
+ .font(.bitchatSystem(size: 12))
+ .foregroundColor(
+ scopeHasNotices || unseenNoticesCount > 0
+ ? Color.orange.opacity(0.8)
+ : palette.secondary.opacity(0.9)
+ )
+ .headerTapTarget()
}
+ .buttonStyle(.plain)
+ .accessibilityLabel(
+ String(localized: "content.accessibility.notices", defaultValue: "Notices", comment: "Accessibility label for the notices button")
+ )
+ .accessibilityValue(
+ unseenNoticesCount > 0
+ ? String(
+ format: String(localized: "content.accessibility.notices_new", defaultValue: "%lld new", comment: "Accessibility value for the notices button when unseen pins arrived"),
+ locale: .current,
+ unseenNoticesCount
+ )
+ : ""
+ )
+ .help(
+ String(localized: "content.header.notices", defaultValue: "Notices: pinned posts for this area and the mesh", comment: "Tooltip for the notices button")
+ )
if case .location(let channel) = locationChannelsModel.selectedChannel {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
@@ -237,47 +272,21 @@ struct ContentHeaderView: View {
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
+ .onReceive(BoardStore.shared.$postsSnapshot) { posts in
+ boardPosts = posts
+ }
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
.environmentObject(peerListModel)
}
- .sheet(isPresented: $showLocationNotes, onDismiss: {
- notesGeohash = nil
- }) {
- Group {
- if let geohash = notesGeohash ?? locationChannelsModel.currentBuildingGeohash {
- LocationNotesView(
- geohash: geohash,
- senderNickname: appChromeModel.nickname
- )
- .environmentObject(locationChannelsModel)
- } else {
- ContentLocationNotesUnavailableView(
- showLocationNotes: $showLocationNotes,
- headerHeight: headerHeight
- )
- .environmentObject(locationChannelsModel)
- }
- }
- .onAppear {
- locationChannelsModel.enableLocationChannels()
- locationChannelsModel.beginLiveRefresh()
- }
- .onDisappear {
- locationChannelsModel.endLiveRefresh()
- }
- .onChange(of: locationChannelsModel.availableChannels) { channels in
- if let current = channels.first(where: { $0.level == .building })?.geohash,
- notesGeohash != current {
- notesGeohash = current
- #if os(iOS)
- let generator = UIImpactFeedbackGenerator(style: .light)
- generator.prepare()
- generator.impactOccurred()
- #endif
- }
- }
+ .sheet(isPresented: $showNotices) {
+ NoticesView(
+ senderNickname: appChromeModel.nickname,
+ board: appChromeModel.boardManager,
+ initialTab: initialNoticesTab
+ )
+ .environmentObject(locationChannelsModel)
}
.onAppear {
locationChannelsModel.refreshMeshChannelsIfNeeded()
@@ -311,6 +320,36 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
+ /// Open the notices sheet on the tab matching the current channel: the
+ /// geohash channel's notices, or the mesh-local board in mesh chat.
+ var initialNoticesTab: NoticesView.Tab {
+ if case .location = locationChannelsModel.selectedChannel {
+ return .geo
+ }
+ return .mesh
+ }
+
+ /// The geo scope the notices sheet would open on: the selected location
+ /// channel, or the device's building geohash when chatting on mesh.
+ var noticesGeoScope: String? {
+ if case .location(let channel) = locationChannelsModel.selectedChannel {
+ return channel.geohash
+ }
+ return locationChannelsModel.currentBuildingGeohash
+ }
+
+ /// Whether either tab of the notices sheet currently has content.
+ var scopeHasNotices: Bool {
+ boardPosts.contains { $0.geohash.isEmpty || $0.geohash == noticesGeoScope }
+ }
+
+ /// New pins in either visible scope since the sheet was last opened.
+ var unseenNoticesCount: Int {
+ let meshCount = boardAlertsModel.unseenCount(forGeohash: "")
+ let geoCount = noticesGeoScope.map { boardAlertsModel.unseenCount(forGeohash: $0) } ?? 0
+ return meshCount + geoCount
+ }
+
/// Whether anyone is actually reachable on the current channel — the
/// state the count icon's color encodes visually.
var headerPeersReachable: Bool {
@@ -334,37 +373,3 @@ private extension ContentHeaderView {
}
}
}
-
-private struct ContentLocationNotesUnavailableView: View {
- @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
- @ThemedPalette private var palette
-
- @Binding var showLocationNotes: Bool
-
- let headerHeight: CGFloat
-
- var body: some View {
- VStack(spacing: 12) {
- HStack {
- Text("content.notes.title")
- .bitchatFont(size: 16, weight: .bold)
- Spacer()
- SheetCloseButton { showLocationNotes = false }
- .foregroundColor(palette.primary)
- }
- .frame(minHeight: headerHeight)
- .padding(.horizontal, 12)
- .themedChromePanel(edge: .top)
- Text("content.notes.location_unavailable")
- .bitchatFont(size: 14)
- .foregroundColor(palette.secondary)
- Button("content.location.enable") {
- locationChannelsModel.enableAndRefresh()
- }
- .buttonStyle(.bordered)
- Spacer()
- }
- .themedSheetBackground()
- .foregroundColor(palette.primary)
- }
-}
diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift
index dd72d8b6..4d9d5c5b 100644
--- a/bitchat/Views/ContentSheetViews.swift
+++ b/bitchat/Views/ContentSheetViews.swift
@@ -221,6 +221,13 @@ private struct ContentPeopleListView: View {
}
)
} else {
+ GroupChatList(
+ groups: peerListModel.groupRows,
+ onTapGroup: { peerID in
+ peerListModel.startConversation(with: peerID)
+ showSidebar = true
+ }
+ )
MeshPeerList(
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
@@ -455,6 +462,10 @@ private struct ContentPrivateChatSheetView: View {
}
private var privacyCaptionText: String {
+ // Group chats are ChaCha20-Poly1305 sealed to the roster's shared key.
+ if privateConversationModel.selectedPeerID?.isGroup == true {
+ return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
+ }
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
@@ -503,30 +514,39 @@ private struct ContentPrivateHeaderInfoButton: View {
var body: some View {
Button(action: {
+ // A group has no single fingerprint to show.
+ guard !headerState.isGroupConversation else { return }
appChromeModel.showFingerprint(for: headerState.headerPeerID)
}) {
HStack(spacing: 6) {
- switch headerState.availability {
- case .bluetoothConnected:
- Image(systemName: "dot.radiowaves.left.and.right")
+ if headerState.isGroupConversation {
+ Image(systemName: "person.3.fill")
.font(.bitchatSystem(size: 14))
.foregroundColor(palette.primary)
- .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
- case .meshReachable:
- Image(systemName: "point.3.filled.connected.trianglepath.dotted")
- .font(.bitchatSystem(size: 14))
- .foregroundColor(palette.primary)
- .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
- case .nostrAvailable:
- Image(systemName: "globe")
- .font(.bitchatSystem(size: 14))
- .foregroundColor(.purple)
- .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
- case .offline:
- // Absence of a glyph was the only offline signal; say it.
- Text("mesh_peers.state.offline")
- .bitchatFont(size: 11)
- .foregroundColor(palette.secondary)
+ .accessibilityLabel(String(localized: "content.accessibility.group_chat", comment: "Accessibility label for the group chat indicator"))
+ } else {
+ switch headerState.availability {
+ case .bluetoothConnected:
+ Image(systemName: "dot.radiowaves.left.and.right")
+ .font(.bitchatSystem(size: 14))
+ .foregroundColor(palette.primary)
+ .accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
+ case .meshReachable:
+ Image(systemName: "point.3.filled.connected.trianglepath.dotted")
+ .font(.bitchatSystem(size: 14))
+ .foregroundColor(palette.primary)
+ .accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
+ case .nostrAvailable:
+ Image(systemName: "globe")
+ .font(.bitchatSystem(size: 14))
+ .foregroundColor(.purple)
+ .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
+ case .offline:
+ // Absence of a glyph was the only offline signal; say it.
+ Text("mesh_peers.state.offline")
+ .bitchatFont(size: 11)
+ .foregroundColor(palette.secondary)
+ }
}
Text(headerState.displayName)
@@ -571,7 +591,9 @@ private struct ContentPrivateHeaderInfoButton: View {
)
)
.accessibilityHint(
- String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
+ headerState.isGroupConversation
+ ? ""
+ : String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
)
.frame(minHeight: headerHeight)
}
diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift
index 503a2bf7..c4ec23be 100644
--- a/bitchat/Views/ContentView.swift
+++ b/bitchat/Views/ContentView.swift
@@ -51,8 +51,6 @@ struct ContentView: View {
@State private var isAtBottomPrivate = true
@State private var autocompleteDebounceTimer: Timer?
@State private var showVerifySheet = false
- @State private var showLocationNotes = false
- @State private var notesGeohash: String?
@State private var imagePreviewURL: URL?
#if os(iOS)
@State private var showImagePicker = false
@@ -151,7 +149,7 @@ struct ContentView: View {
#endif
}
.sheet(isPresented: $appChromeModel.isAppInfoPresented) {
- AppInfoView()
+ AppInfoView(topologyProvider: { appChromeModel.meshTopologyDisplayModel() })
}
.sheet(isPresented: Binding(
get: { appChromeModel.showingFingerprintFor != nil && !appChromeModel.showSidebar && selectedPrivatePeerID == nil },
@@ -294,8 +292,6 @@ struct ContentView: View {
ContentHeaderView(
showSidebar: $appChromeModel.showSidebar,
showVerifySheet: $showVerifySheet,
- showLocationNotes: $showLocationNotes,
- notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift
index 06fc865f..fb4541f5 100644
--- a/bitchat/Views/FingerprintView.swift
+++ b/bitchat/Views/FingerprintView.swift
@@ -37,6 +37,14 @@ struct FingerprintView: View {
}
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
+ static let vouchedBadge: LocalizedStringKey = "fingerprint.badge.vouched"
+ static func vouchedBy(_ count: Int) -> String {
+ String(
+ format: String(localized: "fingerprint.message.vouched_by", comment: "How many people the user verified have vouched for this peer"),
+ locale: .current,
+ count
+ )
+ }
static func unknownPeer() -> String {
String(localized: "common.unknown", comment: "Label for an unknown peer")
}
@@ -146,6 +154,41 @@ struct FingerprintView: View {
}
}
+ // Vouched (transitively verified) status: shown whenever the
+ // peer isn't explicitly verified but people I verified vouch
+ // for them, independent of the current session state.
+ if fingerprintState.isVouched && !fingerprintState.isVerified {
+ VStack(spacing: 8) {
+ HStack(spacing: 6) {
+ Image(systemName: "checkmark.seal")
+ .font(.bitchatSystem(size: 14))
+ .foregroundColor(.teal)
+ Text(Strings.vouchedBadge)
+ .bitchatFont(size: 14, weight: .bold)
+ .foregroundColor(.teal)
+ }
+ .frame(maxWidth: .infinity)
+
+ Text(Strings.vouchedBy(fingerprintState.voucherCount))
+ .bitchatFont(size: 12)
+ .foregroundColor(textColor.opacity(0.7))
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+
+ if !fingerprintState.voucherNames.isEmpty {
+ Text(fingerprintState.voucherNames.joined(separator: ", "))
+ .bitchatFont(size: 12)
+ .foregroundColor(textColor.opacity(0.7))
+ .multilineTextAlignment(.center)
+ .lineLimit(nil)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .padding(.top, 8)
+ .accessibilityElement(children: .combine)
+ }
+
// Verification status
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift
new file mode 100644
index 00000000..8b62d8a2
--- /dev/null
+++ b/bitchat/Views/GroupChatList.swift
@@ -0,0 +1,86 @@
+import BitFoundation
+import SwiftUI
+
+/// Compact "groups" section for the people sheet: one row per private group
+/// this device belongs to, tappable to open the group chat window.
+struct GroupChatList: View {
+ @ThemedPalette private var palette
+
+ let groups: [GroupChatRow]
+ let onTapGroup: (PeerID) -> Void
+
+ private enum Strings {
+ static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list")
+ static let creator = String(localized: "groups.state.creator", comment: "State label for a group the user created")
+ static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
+ static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
+ static let openGroupHint = String(localized: "groups.accessibility.open_group_hint", comment: "Accessibility hint on a group row explaining activation opens the group chat")
+ static let memberCountFormat = String(localized: "groups.member_count %@", comment: "Member count shown next to a group name; placeholder is the count")
+ }
+
+ var body: some View {
+ if !groups.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ Text(Strings.header)
+ .bitchatFont(size: 11, weight: .medium)
+ .foregroundColor(palette.secondary)
+ .padding(.horizontal)
+ .padding(.top, 10)
+ .padding(.bottom, 2)
+ .accessibilityAddTraits(.isHeader)
+
+ ForEach(groups) { group in
+ HStack(spacing: 4) {
+ Image(systemName: "person.3.fill")
+ .font(.bitchatSystem(size: 10))
+ .foregroundColor(palette.primary)
+
+ Text("#\(group.name)")
+ .bitchatFont(size: 14)
+ .foregroundColor(palette.primary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+
+ Text(String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)"))
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+
+ if group.isCreator {
+ Image(systemName: "crown.fill")
+ .font(.bitchatSystem(size: 9))
+ .foregroundColor(.yellow)
+ .help(Strings.creator)
+ }
+
+ Spacer()
+
+ if group.hasUnread {
+ Image(systemName: "envelope.fill")
+ .font(.bitchatSystem(size: 10))
+ .foregroundColor(.orange)
+ .help(Strings.newMessagesTooltip)
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 6)
+ .contentShape(Rectangle())
+ .onTapGesture { onTapGroup(group.peerID) }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(accessibilityDescription(for: group))
+ .accessibilityAddTraits(.isButton)
+ .accessibilityHint(Strings.openGroupHint)
+ }
+ }
+ }
+ }
+
+ private func accessibilityDescription(for group: GroupChatRow) -> String {
+ var parts: [String] = [
+ group.name,
+ String(format: Strings.memberCountFormat, locale: .current, "\(group.memberCount)")
+ ]
+ if group.isCreator { parts.append(Strings.creator) }
+ if group.hasUnread { parts.append(Strings.unread) }
+ return parts.joined(separator: ", ")
+ }
+}
diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift
index 77b45a21..0a6e077a 100644
--- a/bitchat/Views/LocationChannelsSheet.swift
+++ b/bitchat/Views/LocationChannelsSheet.swift
@@ -27,6 +27,8 @@ struct LocationChannelsSheet: View {
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
+ static let gatewayTitle: LocalizedStringKey = "location_channels.gateway.title"
+ static let gatewaySubtitle: LocalizedStringKey = "location_channels.gateway.subtitle"
static let toggleOn: LocalizedStringKey = "common.toggle.on"
static let toggleOff: LocalizedStringKey = "common.toggle.off"
@@ -244,6 +246,8 @@ struct LocationChannelsSheet: View {
sectionDivider
torToggleSection
.padding(.top, 12)
+ gatewayToggleSection
+ .padding(.top, 8)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.bitchatFont(size: 12)
@@ -508,6 +512,32 @@ extension LocationChannelsSheet {
.cornerRadius(8)
}
+ private var gatewayToggleBinding: Binding {
+ Binding(
+ get: { locationChannelsModel.gatewayEnabled },
+ set: { locationChannelsModel.setGatewayEnabled($0) }
+ )
+ }
+
+ private var gatewayToggleSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Toggle(isOn: gatewayToggleBinding) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(Strings.gatewayTitle)
+ .bitchatFont(size: 12, weight: .semibold)
+ .foregroundColor(palette.primary)
+ Text(Strings.gatewaySubtitle)
+ .bitchatFont(size: 11)
+ .foregroundColor(palette.secondary)
+ }
+ }
+ .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
+ }
+ .padding(12)
+ .background(palette.secondary.opacity(0.12))
+ .cornerRadius(8)
+ }
+
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift
deleted file mode 100644
index d2cd3f16..00000000
--- a/bitchat/Views/LocationNotesView.swift
+++ /dev/null
@@ -1,304 +0,0 @@
-import SwiftUI
-
-struct LocationNotesView: View {
- @StateObject private var manager: LocationNotesManager
- let geohash: String
- let senderNickname: String
- let onNotesCountChanged: ((Int) -> Void)?
-
- @ThemedPalette private var palette
- @Environment(\.dynamicTypeSize) private var dynamicTypeSize
- @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
- @Environment(\.dismiss) private var dismiss
- @State private var draft: String = ""
-
- init(
- geohash: String,
- senderNickname: String,
- onNotesCountChanged: ((Int) -> Void)? = nil,
- manager: LocationNotesManager? = nil
- ) {
- let gh = geohash.lowercased()
- self.geohash = gh
- self.senderNickname = senderNickname
- self.onNotesCountChanged = onNotesCountChanged
- _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
- }
-
- private var backgroundColor: Color { palette.background }
- private var accentGreen: Color { palette.accent }
- private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
-
- private enum Strings {
- static let description: LocalizedStringKey = "location_notes.description"
- static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
- static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
- static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
- static let retry: LocalizedStringKey = "location_notes.action.retry"
- static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
- static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
- static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
- static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
- static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
- static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
- }
-
- var body: some View {
-#if os(macOS)
- VStack(spacing: 0) {
- ScrollView {
- VStack(spacing: 0) {
- headerSection
- notesContent
- }
- }
- .themedSurface()
- inputSection
- }
- .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
- .themedSheetBackground()
- .onDisappear { manager.cancel() }
- .onChange(of: geohash) { newValue in
- manager.setGeohash(newValue)
- }
- .onAppear { onNotesCountChanged?(manager.notes.count) }
- .onChange(of: manager.notes.count) { newValue in
- onNotesCountChanged?(newValue)
- }
-#else
- NavigationView {
- VStack(spacing: 0) {
- headerSection
- ScrollView {
- notesContent
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- inputSection
- }
- .themedSurface()
- #if os(iOS)
- .navigationBarTitleDisplayMode(.inline)
- .navigationBarHidden(true)
- #else
- .navigationTitle("")
- #endif
- }
- .themedSheetBackground()
- .onDisappear { manager.cancel() }
- .onChange(of: geohash) { newValue in
- manager.setGeohash(newValue)
- }
- .onAppear { onNotesCountChanged?(manager.notes.count) }
- .onChange(of: manager.notes.count) { newValue in
- onNotesCountChanged?(newValue)
- }
-#endif
- }
-
- private var closeButton: some View {
- SheetCloseButton { dismiss() }
- }
-
- private var headerSection: some View {
- let count = manager.notes.count
- return VStack(alignment: .leading, spacing: 8) {
- HStack(spacing: 12) {
- Text(headerTitle(for: count))
- .bitchatFont(size: 18)
- Spacer()
- closeButton
- }
- if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
- Text(building)
- .bitchatFont(size: 12)
- .foregroundColor(accentGreen)
- } else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
- Text(block)
- .bitchatFont(size: 12)
- .foregroundColor(accentGreen)
- }
- Text(Strings.description)
- .bitchatFont(size: 12)
- .foregroundColor(palette.secondary)
- .fixedSize(horizontal: false, vertical: true)
- if manager.state == .noRelays {
- Text(Strings.relaysPaused)
- .bitchatFont(size: 11)
- .foregroundColor(palette.secondary)
- }
- }
- .padding(.horizontal, 16)
- .padding(.top, 16)
- .padding(.bottom, 12)
- .themedSurface()
- }
-
- private func headerTitle(for count: Int) -> String {
- String(
- format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
- locale: .current,
- "\(geohash) ± 1", count
- )
- }
-
- private var notesContent: some View {
- LazyVStack(alignment: .leading, spacing: 12) {
- if manager.state == .noRelays {
- noRelaysRow
- } else if manager.state == .loading && !manager.initialLoadComplete {
- loadingRow
- } else if manager.notes.isEmpty {
- emptyRow
- } else {
- ForEach(manager.notes) { note in
- noteRow(note)
- }
- }
-
- if let error = manager.errorMessage, manager.state != .noRelays {
- errorRow(message: error)
- }
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 8)
- }
-
- private func noteRow(_ note: LocationNotesManager.Note) -> some View {
- let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
- let ts = timestampText(for: note.createdAt)
- return VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 6) {
- Text(verbatim: "@\(baseName)")
- .bitchatFont(size: 12, weight: .semibold)
- if !ts.isEmpty {
- Text(ts)
- .bitchatFont(size: 11)
- .foregroundColor(palette.secondary)
- }
- Spacer()
- }
- Text(note.content)
- .bitchatFont(size: 14)
- .fixedSize(horizontal: false, vertical: true)
- }
- .padding(.vertical, 4)
- }
-
- private var noRelaysRow: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(Strings.noRelaysNearby)
- .bitchatFont(size: 13, weight: .semibold)
- Text(Strings.relaysRetryHint)
- .bitchatFont(size: 12)
- .foregroundColor(palette.secondary)
- Button(Strings.retry) { manager.refresh() }
- .bitchatFont(size: 12)
- .buttonStyle(.plain)
- }
- .padding(.vertical, 6)
- }
-
- private var loadingRow: some View {
- HStack(spacing: 10) {
- ProgressView()
- Text(Strings.loadingNotes)
- .bitchatFont(size: 12)
- .foregroundColor(palette.secondary)
- Spacer()
- }
- .padding(.vertical, 8)
- }
-
- private var emptyRow: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(Strings.emptyTitle)
- .bitchatFont(size: 13, weight: .semibold)
- Text(Strings.emptySubtitle)
- .bitchatFont(size: 12)
- .foregroundColor(palette.secondary)
- }
- .padding(.vertical, 6)
- }
-
- private func errorRow(message: String) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 6) {
- Image(systemName: "exclamationmark.triangle.fill")
- .bitchatFont(size: 12)
- Text(message)
- .bitchatFont(size: 12)
- Spacer()
- }
- Button(Strings.dismissError) { manager.clearError() }
- .bitchatFont(size: 12)
- .buttonStyle(.plain)
- }
- .padding(.vertical, 6)
- }
-
- private var inputSection: some View {
- HStack(alignment: .top, spacing: 10) {
- TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
- .textFieldStyle(.plain)
- .bitchatFont(size: 14)
- .lineLimit(maxDraftLines, reservesSpace: true)
- .padding(.vertical, 6)
- Button(action: send) {
- Image(systemName: "arrow.up.circle.fill")
- .font(.bitchatSystem(size: 20))
- .foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
- }
- .padding(.top, 2)
- .buttonStyle(.plain)
- .disabled(!sendButtonEnabled)
- }
- .padding(.horizontal, 16)
- .padding(.vertical, 14)
- .themedSurface()
- .overlay(Divider(), alignment: .top)
- }
-
- private func send() {
- guard let content = draft.trimmedOrNilIfEmpty else { return }
- manager.send(content: content, nickname: senderNickname)
- draft = ""
- }
-
- private var sendButtonEnabled: Bool {
- !draft.trimmed.isEmpty && manager.state != .noRelays
- }
-
- // MARK: - Timestamp Formatting
- private func timestampText(for date: Date) -> String {
- let now = Date()
- if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
- let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
- return rel.isEmpty ? "" : "\(rel) ago"
- } else {
- let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
- let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
- return fmt.string(from: date)
- }
- }
-
- private static let relativeFormatter: DateComponentsFormatter = {
- let f = DateComponentsFormatter()
- f.allowedUnits = [.day, .hour, .minute]
- f.maximumUnitCount = 1
- f.unitsStyle = .abbreviated
- f.collapsesLargestUnit = true
- return f
- }()
-
- private static let absDateFormatter: DateFormatter = {
- let f = DateFormatter()
- f.setLocalizedDateFormatFromTemplate("MMM d")
- return f
- }()
-
- private static let absDateYearFormatter: DateFormatter = {
- let f = DateFormatter()
- f.setLocalizedDateFormatFromTemplate("MMM d, y")
- return f
- }()
-}
diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift
index 244cd6dc..b09ef476 100644
--- a/bitchat/Views/MeshPeerList.swift
+++ b/bitchat/Views/MeshPeerList.swift
@@ -25,6 +25,8 @@ struct MeshPeerList: View {
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
+ static let vouched = String(localized: "mesh_peers.state.vouched", comment: "State label for a peer vouched for by someone the user verified")
+ static let vouchedTooltip = String(localized: "mesh_peers.tooltip.vouched", comment: "Tooltip for the vouched (unfilled seal) badge next to a peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
@@ -134,6 +136,16 @@ struct MeshPeerList: View {
.foregroundColor(baseColor)
}
}
+
+ // Vouched (transitively verified): unfilled seal,
+ // deliberately distinct from verified's filled one.
+ // Never shown alongside a verified badge.
+ if peer.showsVouchedBadge {
+ Image(systemName: "checkmark.seal")
+ .font(.bitchatSystem(size: 10))
+ .foregroundColor(baseColor)
+ .help(Strings.vouchedTooltip)
+ }
}
Spacer()
@@ -241,6 +253,7 @@ struct MeshPeerList: View {
parts.append(Strings.offline)
}
}
+ if peer.showsVouchedBadge { parts.append(Strings.vouched) }
if peer.isFavorite { parts.append(Strings.favorite) }
if peer.hasUnread { parts.append(Strings.unread) }
if peer.isBlocked { parts.append(Strings.blocked) }
diff --git a/bitchat/Views/MeshTopologyView.swift b/bitchat/Views/MeshTopologyView.swift
new file mode 100644
index 00000000..a8bc4e3e
--- /dev/null
+++ b/bitchat/Views/MeshTopologyView.swift
@@ -0,0 +1,217 @@
+//
+// MeshTopologyView.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import SwiftUI
+
+/// Display model for the mesh topology map: nodes are known mesh peers,
+/// edges are gossiped `directNeighbors` claims. Built on the main actor from
+/// a `MeshTopologySnapshot` plus the current nickname table.
+struct MeshTopologyDisplayModel {
+ struct Node: Identifiable, Equatable {
+ let id: String
+ let label: String
+ let isSelf: Bool
+ }
+
+ let nodes: [Node]
+ /// Pairs of `Node.id`; every id is present in `nodes`.
+ let edges: [(String, String)]
+
+ static let empty = MeshTopologyDisplayModel(nodes: [], edges: [])
+}
+
+/// Minimal diagnostics sheet: the mesh graph on a circular layout (self in
+/// the center), drawn with Canvas so it stays cheap at any peer count.
+struct MeshTopologyView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.appTheme) private var appTheme
+ @ThemedPalette private var palette
+
+ /// Fetches a fresh model; called on appear and on manual refresh.
+ let provider: @MainActor () -> MeshTopologyDisplayModel
+ @State private var model: MeshTopologyDisplayModel = .empty
+
+ var body: some View {
+ #if os(macOS)
+ VStack(spacing: 0) {
+ HStack {
+ Text("topology.title")
+ .bitchatFont(size: 16, weight: .bold)
+ .foregroundColor(palette.primary)
+ Spacer()
+ refreshButton
+ Button("app_info.done") {
+ dismiss()
+ }
+ .buttonStyle(.plain)
+ .foregroundColor(palette.primary)
+ }
+ .padding()
+ .themedSurface(opacity: 0.95)
+
+ content
+ }
+ .frame(width: 500, height: 520)
+ .themedSheetBackground()
+ #else
+ NavigationView {
+ content
+ .themedSheetBackground()
+ .navigationTitle(Text("topology.title"))
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ refreshButton
+ }
+ ToolbarItem(placement: .navigationBarTrailing) {
+ SheetCloseButton { dismiss() }
+ .foregroundColor(palette.primary)
+ }
+ }
+ }
+ #endif
+ }
+
+ private var refreshButton: some View {
+ Button {
+ model = provider()
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ .font(.bitchatSystem(size: 14))
+ .foregroundColor(palette.primary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(Text("topology.refresh"))
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ VStack(spacing: 12) {
+ if model.nodes.count <= 1 {
+ Spacer()
+ Text("topology.empty")
+ .bitchatFont(size: 14)
+ .foregroundColor(palette.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ Spacer()
+ } else {
+ graphCanvas
+ .padding(.horizontal, 8)
+ }
+
+ VStack(spacing: 4) {
+ Text(summaryText)
+ .bitchatFont(size: 13, weight: .semibold)
+ .foregroundColor(palette.primary)
+ Text("topology.caption")
+ .bitchatFont(size: 11)
+ .foregroundColor(palette.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.horizontal)
+ .padding(.bottom, 16)
+ }
+ .onAppear { model = provider() }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel(Text(summaryText))
+ }
+
+ private var summaryText: String {
+ String(
+ format: String(
+ localized: "topology.summary",
+ comment: "Topology map summary: number of peers and links"
+ ),
+ locale: .current,
+ model.nodes.count,
+ model.edges.count
+ )
+ }
+
+ private var graphCanvas: some View {
+ Canvas { context, size in
+ let positions = Self.layout(nodes: model.nodes, in: size)
+ let fontDesign = appTheme.bodyFontDesign
+
+ // Edges first so nodes draw on top.
+ for (fromID, toID) in model.edges {
+ guard let from = positions[fromID], let to = positions[toID] else { continue }
+ var path = Path()
+ path.move(to: from)
+ path.addLine(to: to)
+ context.stroke(path, with: .color(palette.secondary.opacity(0.45)), lineWidth: 1)
+ }
+
+ for node in model.nodes {
+ guard let center = positions[node.id] else { continue }
+ let radius: CGFloat = node.isSelf ? 7 : 5
+ let dot = Path(ellipseIn: CGRect(
+ x: center.x - radius,
+ y: center.y - radius,
+ width: radius * 2,
+ height: radius * 2
+ ))
+ context.fill(dot, with: .color(node.isSelf ? palette.accent : palette.primary))
+ if node.isSelf {
+ let ring = Path(ellipseIn: CGRect(
+ x: center.x - radius - 3,
+ y: center.y - radius - 3,
+ width: (radius + 3) * 2,
+ height: (radius + 3) * 2
+ ))
+ context.stroke(ring, with: .color(palette.accent.opacity(0.6)), lineWidth: 1)
+ }
+ context.draw(
+ Text(node.label)
+ .font(.system(size: 10, design: fontDesign))
+ .foregroundColor(node.isSelf ? palette.accent : palette.secondary),
+ at: CGPoint(x: center.x, y: center.y + radius + 4),
+ anchor: .top
+ )
+ }
+ }
+ .accessibilityHidden(true) // The combined summary label narrates the graph.
+ }
+
+ /// Circular layout: self in the center, everyone else evenly spaced on a
+ /// ring. Deterministic (nodes arrive sorted), so refreshes don't shuffle.
+ static func layout(nodes: [MeshTopologyDisplayModel.Node], in size: CGSize) -> [String: CGPoint] {
+ let center = CGPoint(x: size.width / 2, y: size.height / 2)
+ // Leave room for the label row under each ring node.
+ let radius = max(20, min(size.width, size.height) / 2 - 36)
+ var positions: [String: CGPoint] = [:]
+
+ let ringNodes = nodes.filter { !$0.isSelf }
+ for node in nodes where node.isSelf {
+ positions[node.id] = center
+ }
+ for (index, node) in ringNodes.enumerated() {
+ let angle = (2 * CGFloat.pi * CGFloat(index)) / CGFloat(max(1, ringNodes.count)) - CGFloat.pi / 2
+ positions[node.id] = CGPoint(
+ x: center.x + radius * cos(angle),
+ y: center.y + radius * sin(angle)
+ )
+ }
+ return positions
+ }
+}
+
+#Preview("Topology") {
+ MeshTopologyView(provider: {
+ MeshTopologyDisplayModel(
+ nodes: [
+ .init(id: "self", label: "me", isSelf: true),
+ .init(id: "a", label: "alice", isSelf: false),
+ .init(id: "b", label: "bob", isSelf: false),
+ .init(id: "c", label: "carol", isSelf: false)
+ ],
+ edges: [("self", "a"), ("a", "b"), ("self", "c")]
+ )
+ })
+}
diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift
index bd653ec9..c684d3f1 100644
--- a/bitchat/Views/MessageTextHelpers.swift
+++ b/bitchat/Views/MessageTextHelpers.swift
@@ -21,17 +21,20 @@ extension String {
return current >= threshold
}
- // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
+ // 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.
func extractCashuLinks(max: Int = 3) -> [String] {
let regex = MessageFormattingEngine.Patterns.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
- for m in regex.matches(in: self, range: range) {
- if m.numberOfRanges > 0 {
- let token = ns.substring(with: m.range(at: 0))
- let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
- found.append("cashu:\(enc)")
+ for m in regex.matches(in: self, range: range) where m.numberOfRanges > 0 {
+ let token = ns.substring(with: m.range(at: 0))
+ // Dedup: repeated tokens are one bearer instrument (and duplicate
+ // ForEach IDs) — one chip is enough.
+ if !found.contains(token) {
+ found.append(token)
if found.count >= max { break }
}
}
diff --git a/bitchat/Views/NoticesView.swift b/bitchat/Views/NoticesView.swift
new file mode 100644
index 00000000..692158f1
--- /dev/null
+++ b/bitchat/Views/NoticesView.swift
@@ -0,0 +1,560 @@
+//
+// NoticesView.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import SwiftUI
+
+/// The unified notices sheet behind the header's pin icon: one place for
+/// everything pinned around you, with a scope toggle.
+///
+/// - geo: the current geohash's notices — mesh-synced board posts merged and
+/// deduped with Nostr kind-1 location notes, so you also see notices from
+/// people who aren't on your mesh.
+/// - mesh: the mesh-local board only (empty geohash, fully offline).
+struct NoticesView: View {
+ enum Tab: Hashable {
+ case geo
+ case mesh
+ }
+
+ let senderNickname: String
+ @ObservedObject var board: BoardManager
+
+ @ThemedPalette private var palette
+ @Environment(\.dynamicTypeSize) private var dynamicTypeSize
+ @Environment(\.dismiss) private var dismiss
+ @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
+ @State private var tab: Tab
+ @State private var draft: String = ""
+ @State private var urgent = false
+ @State private var expiryDays = 7
+
+ /// Injected notes manager for tests; live use derives one per geohash.
+ private let notesManager: LocationNotesManager?
+
+ init(
+ senderNickname: String,
+ board: BoardManager,
+ initialTab: Tab,
+ notesManager: LocationNotesManager? = nil
+ ) {
+ self.senderNickname = senderNickname
+ self.board = board
+ self.notesManager = notesManager
+ _tab = State(initialValue: initialTab)
+ }
+
+ private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
+
+ /// The geohash the geo tab is scoped to: the selected location channel,
+ /// or the device's building geohash when chatting on mesh.
+ private var geoGeohash: String? {
+ if case .location(let channel) = locationChannelsModel.selectedChannel {
+ return channel.geohash
+ }
+ return locationChannelsModel.currentBuildingGeohash
+ }
+
+ /// The geo scope comes from device location only when no location channel
+ /// is selected; that's the case that needs the location machinery.
+ private var geoTabNeedsDeviceLocation: Bool {
+ if case .location = locationChannelsModel.selectedChannel { return false }
+ return true
+ }
+
+ private var activeGeohash: String? {
+ switch tab {
+ case .geo: return geoGeohash
+ case .mesh: return ""
+ }
+ }
+
+ enum Strings {
+ static let title = String(localized: "notices.title", defaultValue: "notices", comment: "Title prefix of the unified notices sheet")
+ static let geoTab = String(localized: "notices.tab.geo", defaultValue: "geo", comment: "Segmented control label for geohash-scoped notices")
+ static let meshTab = String(localized: "notices.tab.mesh", defaultValue: "mesh", comment: "Segmented control label for mesh-local notices")
+ static let scopePicker = String(localized: "notices.accessibility.scope", defaultValue: "Notices scope", comment: "Accessibility label for the geo/mesh scope toggle")
+ // The pre-merge location-notes explainer, reused so its existing
+ // translations carry over.
+ static let geoDescription = String(localized: "location_notes.description", comment: "Explainer for the geo tab of the notices sheet")
+ static let meshDescription = String(localized: "notices.description.mesh", defaultValue: "pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.", comment: "Explainer for the mesh tab of the notices sheet")
+ static let emptyTitle = String(localized: "board.empty_title", defaultValue: "no notices yet", comment: "Title shown when the board has no posts")
+ static let emptySubtitle = String(localized: "board.empty_subtitle", defaultValue: "pin the first notice for people around here.", comment: "Subtitle shown when the board has no posts")
+ static let urgentBadge = String(localized: "board.urgent_badge", defaultValue: "urgent", comment: "Badge shown on urgent board posts")
+ static let urgentToggle = String(localized: "board.compose.urgent", defaultValue: "urgent", comment: "Label for the urgent toggle in the board composer")
+ static let placeholder = String(localized: "board.compose.placeholder", defaultValue: "post a notice…", comment: "Placeholder for the board composer text field")
+ static let send = String(localized: "board.accessibility.post", defaultValue: "Post notice", comment: "Accessibility label for the board post button")
+ static let deleteAction = String(localized: "board.action.delete", defaultValue: "delete", comment: "Delete action for own board posts")
+ static let expiryLabel = String(localized: "board.compose.expiry", defaultValue: "expires in", comment: "Label for the board post expiry picker")
+ static let closeHint = String(localized: "notices.accessibility.close", defaultValue: "Close notices", comment: "Accessibility label for the notices close button")
+ static let meshSource = String(localized: "notices.source.mesh", defaultValue: "mesh", comment: "Source badge for notices carried by the mesh")
+ static let nostrSource = String(localized: "notices.source.nostr", defaultValue: "net", comment: "Source badge for notices seen on internet relays")
+ static let locationUnavailable = String(localized: "content.notes.location_unavailable", comment: "Shown when the device location is unavailable for geo notices")
+ static let enableLocation = String(localized: "content.location.enable", comment: "Button enabling location for geo notices")
+ static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
+ static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
+ static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
+ static let retry: LocalizedStringKey = "location_notes.action.retry"
+ static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
+
+ static func expiryDaysOption(_ days: Int) -> String {
+ String(
+ format: String(localized: "board.compose.expiry_days", defaultValue: "%lldd", comment: "Expiry picker option, number of days abbreviated"),
+ locale: .current,
+ days
+ )
+ }
+
+ static func rowAccessibilityLabel(author: String, content: String, urgent: Bool) -> String {
+ let base = String(
+ format: String(localized: "board.accessibility.post_row", defaultValue: "Notice from %@: %@", comment: "Accessibility label for a board post row"),
+ locale: .current,
+ author, content
+ )
+ return urgent ? "\(urgentBadge), \(base)" : base
+ }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ headerSection
+ contentSection
+ if activeGeohash != nil {
+ composer
+ }
+ }
+ .themedSurface()
+ #if os(macOS)
+ .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
+ #endif
+ .themedSheetBackground()
+ .onAppear { beginGeoLocationIfNeeded() }
+ .onChange(of: tab) { newTab in
+ if newTab == .geo {
+ beginGeoLocationIfNeeded()
+ } else {
+ locationChannelsModel.endLiveRefresh()
+ }
+ }
+ // Catches permission granted from the geo tab's enable button.
+ .onChange(of: locationChannelsModel.permissionState) { _ in
+ beginGeoLocationIfNeeded()
+ }
+ .onDisappear { locationChannelsModel.endLiveRefresh() }
+ }
+
+ /// The geo tab tracks the device's building geohash while on mesh; keep
+ /// location fresh only in that case (a selected location channel already
+ /// fixes the scope).
+ private func beginGeoLocationIfNeeded() {
+ guard tab == .geo, geoTabNeedsDeviceLocation,
+ locationChannelsModel.permissionState == .authorized else { return }
+ locationChannelsModel.enableLocationChannels()
+ locationChannelsModel.beginLiveRefresh()
+ }
+
+ private var headerSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 12) {
+ Text(verbatim: scopeTitle)
+ .bitchatFont(size: 18)
+ Spacer()
+ SheetCloseButton { dismiss() }
+ .accessibilityLabel(Strings.closeHint)
+ }
+ Picker(Strings.scopePicker, selection: $tab) {
+ Text(Strings.geoTab).tag(Tab.geo)
+ Text(Strings.meshTab).tag(Tab.mesh)
+ }
+ .pickerStyle(.segmented)
+ .accessibilityLabel(Strings.scopePicker)
+ Text(tab == .geo ? Strings.geoDescription : Strings.meshDescription)
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.horizontal, 16)
+ .padding(.top, 16)
+ .padding(.bottom, 12)
+ .themedSurface()
+ }
+
+ private var scopeTitle: String {
+ switch tab {
+ case .mesh:
+ return "\(Strings.title) @ #mesh"
+ case .geo:
+ if let geohash = geoGeohash {
+ return "\(Strings.title) @ #\(geohash)"
+ }
+ return Strings.title
+ }
+ }
+
+ @ViewBuilder
+ private var contentSection: some View {
+ switch tab {
+ case .mesh:
+ NoticesList(
+ items: UnifiedNotices.merge(posts: board.posts(forGeohash: ""), notes: []),
+ showsSource: false,
+ board: board,
+ notesManager: nil
+ )
+ case .geo:
+ if let geohash = geoGeohash {
+ GeoNoticesList(geohash: geohash, board: board, manager: notesManager)
+ } else {
+ locationUnavailableSection
+ }
+ }
+ }
+
+ private var locationUnavailableSection: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(Strings.locationUnavailable)
+ .bitchatFont(size: 14)
+ .foregroundColor(palette.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ Button(Strings.enableLocation) {
+ locationChannelsModel.enableAndRefresh()
+ }
+ .buttonStyle(.bordered)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .themedSurface()
+ }
+
+ private var composer: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 10) {
+ TextField(Strings.placeholder, text: $draft, axis: .vertical)
+ .textFieldStyle(.plain)
+ .bitchatFont(size: 14)
+ .lineLimit(maxDraftLines, reservesSpace: true)
+ .padding(.vertical, 6)
+ Button(action: send) {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.bitchatSystem(size: 20))
+ .foregroundColor(sendEnabled ? palette.accent : .secondary)
+ }
+ .padding(.top, 2)
+ .buttonStyle(.plain)
+ .disabled(!sendEnabled)
+ .accessibilityLabel(Strings.send)
+ }
+ // Urgency and expiry only travel with the mesh copy — the bridged
+ // Nostr note carries neither, so relay-side readers would never
+ // see them. Offer the controls only where they fully apply.
+ if tab == .mesh {
+ HStack(spacing: 12) {
+ Toggle(isOn: $urgent) {
+ Text(Strings.urgentToggle)
+ .bitchatFont(size: 12)
+ .foregroundColor(urgent ? palette.alertRed : palette.secondary)
+ }
+ .toggleStyle(.switch)
+ .fixedSize()
+ .accessibilityLabel(Strings.urgentToggle)
+ Spacer()
+ Text(Strings.expiryLabel)
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+ Picker(Strings.expiryLabel, selection: $expiryDays) {
+ ForEach([1, 3, 7], id: \.self) { days in
+ Text(Strings.expiryDaysOption(days)).tag(days)
+ }
+ }
+ .pickerStyle(.segmented)
+ .fixedSize()
+ .accessibilityLabel(Strings.expiryLabel)
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ .themedSurface()
+ .overlay(Divider(), alignment: .top)
+ }
+
+ private var sendEnabled: Bool {
+ let trimmed = draft.trimmed
+ return !trimmed.isEmpty && trimmed.utf8.count <= BoardWireConstants.contentMaxBytes
+ }
+
+ private func send() {
+ guard let geohash = activeGeohash, let content = draft.trimmedOrNilIfEmpty else { return }
+ // Geo posts go to the board and are bridged to Nostr by BoardManager,
+ // so mesh and internet see the same notice. They always use the
+ // defaults: non-urgent, 7-day expiry (NIP-40 on the bridged copy).
+ let sent = board.createPost(
+ content: content,
+ geohash: geohash,
+ urgent: tab == .mesh && urgent,
+ expiryDays: tab == .mesh ? expiryDays : 7,
+ nickname: senderNickname
+ )
+ if sent {
+ draft = ""
+ urgent = false
+ }
+ }
+}
+
+/// The geo tab's list: owns the Nostr notes subscription for the scope
+/// geohash and merges it with the board posts for the same geohash.
+private struct GeoNoticesList: View {
+ let geohash: String
+ @ObservedObject var board: BoardManager
+ @StateObject private var notesManager: LocationNotesManager
+
+ init(geohash: String, board: BoardManager, manager: LocationNotesManager? = nil) {
+ let gh = geohash.lowercased()
+ self.geohash = gh
+ self.board = board
+ _notesManager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
+ }
+
+ var body: some View {
+ NoticesList(
+ items: UnifiedNotices.merge(
+ posts: board.posts(forGeohash: geohash),
+ notes: notesManager.notes
+ ),
+ showsSource: true,
+ board: board,
+ notesManager: notesManager
+ )
+ .onChange(of: geohash) { newValue in
+ notesManager.setGeohash(newValue)
+ }
+ .onDisappear { notesManager.cancel() }
+ }
+}
+
+/// Renders merged notices with per-source affordances: swipe-delete for own
+/// items and a mesh/net badge when sources mix.
+private struct NoticesList: View {
+ let items: [NoticeItem]
+ let showsSource: Bool
+ let board: BoardManager
+ let notesManager: LocationNotesManager?
+
+ @ThemedPalette private var palette
+
+ private typealias Strings = NoticesView.Strings
+
+ var body: some View {
+ Group {
+ if items.isEmpty {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 4) {
+ statusRows
+ if showEmptyState {
+ Text(Strings.emptyTitle)
+ .bitchatFont(size: 13, weight: .semibold)
+ Text(Strings.emptySubtitle)
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ }
+ } else {
+ List {
+ statusRows
+ .listRowBackground(palette.background)
+ .listRowSeparatorTint(palette.divider)
+ ForEach(items) { item in
+ row(item)
+ .listRowBackground(palette.background)
+ .listRowSeparatorTint(palette.divider)
+ }
+ }
+ .listStyle(.plain)
+ .scrollContentBackground(.hidden)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .themedSurface()
+ }
+
+ /// Notes may still be loading or unreachable; only claim "no notices yet"
+ /// once the sources settled.
+ private var showEmptyState: Bool {
+ guard let notesManager else { return true }
+ return notesManager.initialLoadComplete && notesManager.state != .loading
+ }
+
+ @ViewBuilder
+ private var statusRows: some View {
+ if let notesManager {
+ if notesManager.state == .loading && !notesManager.initialLoadComplete {
+ HStack(spacing: 10) {
+ ProgressView()
+ Text(Strings.loadingNotes)
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+ Spacer()
+ }
+ .padding(.vertical, 8)
+ } else if notesManager.state == .noRelays {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(Strings.noRelaysNearby)
+ .bitchatFont(size: 13, weight: .semibold)
+ Text(Strings.relaysRetryHint)
+ .bitchatFont(size: 12)
+ .foregroundColor(palette.secondary)
+ Button(Strings.retry) { notesManager.refresh() }
+ .bitchatFont(size: 12)
+ .buttonStyle(.plain)
+ }
+ .padding(.bottom, 8)
+ } else if let error = notesManager.errorMessage {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .bitchatFont(size: 12)
+ Text(error)
+ .bitchatFont(size: 12)
+ Spacer()
+ }
+ Button(Strings.dismissError) { notesManager.clearError() }
+ .bitchatFont(size: 12)
+ .buttonStyle(.plain)
+ }
+ .padding(.bottom, 8)
+ }
+ }
+ }
+
+ private func canDelete(_ item: NoticeItem) -> Bool {
+ switch item.source {
+ case .board(let post):
+ return board.isOwnPost(post)
+ case .nostr(let note):
+ return notesManager?.isOwnNote(note) ?? false
+ }
+ }
+
+ private func delete(_ item: NoticeItem) {
+ switch item.source {
+ case .board(let post):
+ // Tombstones the board post and retracts the bridged Nostr copy.
+ board.deletePost(post)
+ case .nostr(let note):
+ notesManager?.delete(note: note)
+ }
+ }
+
+ private func row(_ item: NoticeItem) -> some View {
+ let isOwn = canDelete(item)
+ return VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ if item.isUrgent {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.bitchatSystem(size: 11))
+ .foregroundColor(palette.alertRed)
+ Text(Strings.urgentBadge)
+ .bitchatFont(size: 11, weight: .semibold)
+ .foregroundColor(palette.alertRed)
+ }
+ Text(verbatim: "@\(item.author)")
+ .bitchatFont(size: 12, weight: .semibold)
+ Text(Self.timestampText(for: item.createdAt))
+ .bitchatFont(size: 11)
+ .foregroundColor(palette.secondary)
+ Spacer()
+ if showsSource {
+ sourceBadge(item)
+ }
+ if isOwn {
+ Button {
+ delete(item)
+ } label: {
+ Image(systemName: "trash")
+ .font(.bitchatSystem(size: 12))
+ .foregroundColor(palette.secondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(Strings.deleteAction)
+ }
+ }
+ Text(item.content)
+ .bitchatFont(size: 14)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.vertical, 4)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(Strings.rowAccessibilityLabel(author: item.author, content: item.content, urgent: item.isUrgent))
+ .accessibilityActions {
+ if isOwn {
+ Button(Strings.deleteAction) { delete(item) }
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ if isOwn {
+ Button(role: .destructive) {
+ delete(item)
+ } label: {
+ Label(Strings.deleteAction, systemImage: "trash")
+ }
+ }
+ }
+ }
+
+ private func sourceBadge(_ item: NoticeItem) -> some View {
+ HStack(spacing: 3) {
+ Image(systemName: item.isBoardPost ? "antenna.radiowaves.left.and.right" : "globe")
+ .font(.bitchatSystem(size: 10))
+ Text(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
+ .bitchatFont(size: 10)
+ }
+ .foregroundColor(palette.secondary.opacity(0.8))
+ .accessibilityLabel(item.isBoardPost ? Strings.meshSource : Strings.nostrSource)
+ }
+
+ // MARK: - Timestamp Formatting
+
+ private static func timestampText(for date: Date) -> String {
+ let now = Date()
+ if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
+ let rel = relativeFormatter.string(from: date, to: now) ?? ""
+ return rel.isEmpty ? "" : "\(rel) ago"
+ }
+ let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
+ return (sameYear ? absDateFormatter : absDateYearFormatter).string(from: date)
+ }
+
+ private static let relativeFormatter: DateComponentsFormatter = {
+ let f = DateComponentsFormatter()
+ f.allowedUnits = [.day, .hour, .minute]
+ f.maximumUnitCount = 1
+ f.unitsStyle = .abbreviated
+ f.collapsesLargestUnit = true
+ return f
+ }()
+
+ private static let absDateFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.setLocalizedDateFormatFromTemplate("MMM d")
+ return f
+ }()
+
+ private static let absDateYearFormatter: DateFormatter = {
+ let f = DateFormatter()
+ f.setLocalizedDateFormatFromTemplate("MMM d, y")
+ return f
+ }()
+}
diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift
index 0b70f0e4..69b7fc89 100644
--- a/bitchatTests/AppArchitectureTests.swift
+++ b/bitchatTests/AppArchitectureTests.swift
@@ -591,6 +591,45 @@ struct AppArchitectureTests {
#expect(!verificationModel.isVerified(peerID: peerID))
}
+ @Test("VerificationModel refreshes when peer trust changes (vouch accepted)")
+ @MainActor
+ func verificationModelRefreshesOnPeerTrustChange() async {
+ let viewModel = makeArchitectureViewModel()
+ var privateConversationModel: PrivateConversationModel? = PrivateConversationModel(
+ chatViewModel: viewModel,
+ conversations: viewModel.conversations,
+ locationChannelsModel: LocationChannelsModel(manager: makeArchitectureLocationManager())
+ )
+ let verificationModel = VerificationModel(
+ chatViewModel: viewModel,
+ privateConversationModel: privateConversationModel!
+ )
+
+ // PrivateConversationModel happens to observe the same notification
+ // and re-assign its published selection, which would ripple into
+ // VerificationModel; release it so this test pins VerificationModel's
+ // own subscription rather than that incidental chain.
+ privateConversationModel = nil
+
+ // The bound @Published sources replay their current values on
+ // subscription; let those initial main-queue emissions settle so the
+ // sink below observes only the trust-change signal.
+ try? await Task.sleep(nanoseconds: 100_000_000)
+
+ // ChatVouchCoordinator.notifyPeerTrustChanged() signals accepted
+ // vouches via "peerStatusUpdated"; an open fingerprint sheet must
+ // re-render its vouched badge from that signal alone.
+ var refreshed = false
+ let cancellable = verificationModel.objectWillChange.sink { _ in
+ refreshed = true
+ }
+ defer { cancellable.cancel() }
+
+ NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
+ await waitUntil { refreshed }
+ #expect(refreshed)
+ }
+
@Test("PeerListModel publishes mesh and geohash directory state")
@MainActor
func peerListModelPublishesDirectoryState() async {
diff --git a/bitchatTests/BLEServiceCoreTests.swift b/bitchatTests/BLEServiceCoreTests.swift
index fe7624ec..6a9cdfc1 100644
--- a/bitchatTests/BLEServiceCoreTests.swift
+++ b/bitchatTests/BLEServiceCoreTests.swift
@@ -216,6 +216,69 @@ struct BLEServiceCoreTests {
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
))
}
+
+ /// Pings are unsigned, so their claimed sender is attacker-controlled.
+ /// The pong budget must be keyed on the ingress link (the directly
+ /// connected peer that delivered the packet): rotating forged sender IDs
+ /// over one link exhausts one budget instead of resetting it, so a single
+ /// malicious link cannot turn /ping into an amplification primitive.
+ @Test
+ func meshPingResponseBudget_isPerIngressLinkNotClaimedSender() async throws {
+ let ble = makeService()
+ let outbound = OutboundPacketTap()
+ ble._test_onOutboundPacket = outbound.record
+
+ let link = PeerID(str: "1122334455667788")
+ let budget = TransportConfig.meshPingInboundMaxPerLink
+ let myRecipientData = try #require(Data(hexString: ble.myPeerID.id))
+
+ for i in 0..<(budget * 2) {
+ // A fresh forged sender for every ping, all arriving on one link.
+ let forgedSender = PeerID(str: String(format: "%016x", 0xA0_0000 + i))
+ var nonce = Data(repeating: 0, count: MeshPingPayload.nonceLength)
+ nonce[0] = UInt8(i)
+ let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
+ let packet = BitchatPacket(
+ type: MessageType.ping.rawValue,
+ senderID: Data(hexString: forgedSender.id) ?? Data(),
+ recipientID: myRecipientData,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: payload.encode(),
+ signature: nil,
+ ttl: 7
+ )
+ ble._test_handlePacket(packet, fromPeerID: link, preseedPeer: false)
+ }
+
+ let reachedBudget = await TestHelpers.waitUntil(
+ { outbound.count(ofType: .pong) >= budget },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(reachedBudget)
+ // Give any over-budget pong a chance to surface, then confirm the
+ // rotated sender IDs never bought a sixth response.
+ let exceededBudget = await TestHelpers.waitUntil(
+ { outbound.count(ofType: .pong) > budget },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!exceededBudget)
+ #expect(outbound.count(ofType: .pong) == budget)
+ }
+}
+
+/// Thread-safe capture of packets leaving the service under test.
+private final class OutboundPacketTap {
+ private let lock = NSLock()
+ private var packets: [BitchatPacket] = []
+
+ func record(_ packet: BitchatPacket) {
+ lock.lock(); packets.append(packet); lock.unlock()
+ }
+
+ func count(ofType type: MessageType) -> Int {
+ lock.lock(); defer { lock.unlock() }
+ return packets.filter { $0.type == type.rawValue }.count
+ }
}
private func makeService() -> BLEService {
diff --git a/bitchatTests/CashuTokenDecoderTests.swift b/bitchatTests/CashuTokenDecoderTests.swift
new file mode 100644
index 00000000..890a7eeb
--- /dev/null
+++ b/bitchatTests/CashuTokenDecoderTests.swift
@@ -0,0 +1,350 @@
+//
+// CashuTokenDecoderTests.swift
+// bitchatTests
+//
+// Tests for the Cashu token summary decoder: V3 JSON decode, minimal V4
+// CBOR traversal, URI normalization, detection ranges, and adversarial
+// (truncated / garbage / huge) input. The decoder renders attacker-controlled
+// message content, so "never crash" matters as much as "decode correctly".
+// This is free and unencumbered software released into the public domain.
+//
+
+import Foundation
+import Testing
+@testable import bitchat
+
+struct CashuTokenDecoderTests {
+
+ // MARK: - Token Builders
+
+ private func base64URL(_ data: Data) -> String {
+ data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ private func makeV3Token(
+ entries: [(mint: String, amounts: [Int])],
+ unit: String? = "sat",
+ memo: String? = nil
+ ) -> String {
+ var json: [String: Any] = [
+ "token": entries.map { entry in
+ [
+ "mint": entry.mint,
+ "proofs": entry.amounts.map {
+ ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] as [String: Any]
+ }
+ ] as [String: Any]
+ }
+ ]
+ if let unit { json["unit"] = unit }
+ if let memo { json["memo"] = memo }
+ let data = try! JSONSerialization.data(withJSONObject: json)
+ return "cashuA" + base64URL(data)
+ }
+
+ /// Tiny deterministic CBOR encoder (definite lengths only) for building
+ /// V4 test tokens without depending on the decoder under test.
+ private enum CBOREncode {
+ static func head(_ major: UInt8, _ value: UInt64) -> [UInt8] {
+ switch value {
+ case 0...23:
+ return [(major << 5) | UInt8(value)]
+ case 24...0xFF:
+ return [(major << 5) | 24, UInt8(value)]
+ case 0x100...0xFFFF:
+ return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)]
+ default:
+ return [(major << 5) | 26,
+ UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
+ UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
+ }
+ }
+ static func uint(_ v: UInt64) -> [UInt8] { head(0, v) }
+ static func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b }
+ static func text(_ s: String) -> [UInt8] {
+ let utf8 = Array(s.utf8)
+ return head(3, UInt64(utf8.count)) + utf8
+ }
+ static func array(_ items: [[UInt8]]) -> [UInt8] {
+ head(4, UInt64(items.count)) + items.flatMap { $0 }
+ }
+ static func map(_ pairs: [(String, [UInt8])]) -> [UInt8] {
+ head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 }
+ }
+ }
+
+ private func makeV4Token(
+ mint: String = "https://mint.example.com",
+ unit: String = "sat",
+ memo: String? = nil,
+ amounts: [UInt64] = [1, 4]
+ ) -> String {
+ var pairs: [(String, [UInt8])] = [
+ ("m", CBOREncode.text(mint)),
+ ("u", CBOREncode.text(unit))
+ ]
+ if let memo { pairs.append(("d", CBOREncode.text(memo))) }
+ let proofs = amounts.map { amount in
+ CBOREncode.map([
+ ("a", CBOREncode.uint(amount)),
+ ("s", CBOREncode.text("secret")),
+ ("c", CBOREncode.bytes([0x02, 0xAB, 0xCD]))
+ ])
+ }
+ pairs.append(("t", CBOREncode.array([
+ CBOREncode.map([
+ ("i", CBOREncode.bytes([0x00, 0xAD, 0x26, 0x8C])),
+ ("p", CBOREncode.array(proofs))
+ ])
+ ])))
+ return "cashuB" + base64URL(Data(CBOREncode.map(pairs)))
+ }
+
+ // MARK: - V3 Decode
+
+ @Test func v3DecodeValidToken() {
+ let token = makeV3Token(
+ entries: [("https://mint.example.com", [2, 8])],
+ unit: "sat",
+ memo: "thanks!"
+ )
+ let info = CashuTokenDecoder.decode(token)
+ #expect(info != nil)
+ #expect(info?.version == "A")
+ #expect(info?.amount == 10)
+ #expect(info?.unit == "sat")
+ #expect(info?.mintHost == "mint.example.com")
+ #expect(info?.memo == "thanks!")
+ #expect(info?.displayAmount == "10 sat")
+ }
+
+ @Test func v3AmountSumsAcrossEntriesAndProofs() {
+ let token = makeV3Token(entries: [
+ ("https://a.mint.example", [1, 2, 4]),
+ ("https://b.mint.example", [8, 16])
+ ])
+ let info = CashuTokenDecoder.decode(token)
+ #expect(info?.amount == 31)
+ // First mint wins for the display host
+ #expect(info?.mintHost == "a.mint.example")
+ }
+
+ @Test func v3MissingUnitDefaultsToSatForDisplay() {
+ let token = makeV3Token(entries: [("https://mint.example.com", [5])], unit: nil)
+ let info = CashuTokenDecoder.decode(token)
+ #expect(info?.unit == nil)
+ #expect(info?.displayAmount == "5 sat")
+ }
+
+ @Test func v3RejectsNonsenseAmounts() {
+ // Negative and absurd amounts must not poison the sum
+ let json: [String: Any] = [
+ "token": [[
+ "mint": "https://mint.example.com",
+ "proofs": [
+ ["amount": -5, "id": "x", "secret": "s", "C": "c"],
+ ["amount": 3, "id": "x", "secret": "s", "C": "c"]
+ ]
+ ] as [String: Any]]
+ ]
+ let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json))
+ #expect(CashuTokenDecoder.decode(token)?.amount == 3)
+ }
+
+ @Test func v3MemoIsSanitizedForDisplay() {
+ let token = makeV3Token(
+ entries: [("https://mint.example.com", [1])],
+ memo: "line1\nline2\u{0007}" + String(repeating: "x", count: 300)
+ )
+ let memo = CashuTokenDecoder.decode(token)?.memo
+ #expect(memo != nil)
+ #expect(memo?.contains("\n") == false)
+ #expect(memo?.contains("\u{0007}") == false)
+ #expect((memo?.count ?? 0) <= 80)
+ }
+
+ // MARK: - V4 (CBOR) Decode
+
+ @Test func v4DecodeValidToken() {
+ let token = makeV4Token(memo: "Thank you", amounts: [1, 4, 16])
+ let info = CashuTokenDecoder.decode(token)
+ #expect(info?.version == "B")
+ #expect(info?.amount == 21)
+ #expect(info?.unit == "sat")
+ #expect(info?.mintHost == "mint.example.com")
+ #expect(info?.memo == "Thank you")
+ }
+
+ @Test func v4UnparseableCBORDegradesToGenericToken() {
+ // Valid base64 payload, but not CBOR we can walk: still a token,
+ // rendered as a generic chip with no amount.
+ let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02]))
+ let info = CashuTokenDecoder.decode(token)
+ #expect(info?.version == "B")
+ #expect(info?.amount == nil)
+ #expect(info?.mintHost == nil)
+ }
+
+ // MARK: - Strict Mode (used by the /pay SEND path)
+
+ @Test func strictAcceptsValidV3WithPositiveAmount() {
+ let token = makeV3Token(entries: [("https://mint.example.com", [2, 8])])
+ let info = CashuTokenDecoder.decode(token, strict: true)
+ #expect(info?.version == "A")
+ #expect(info?.amount == 10)
+ }
+
+ @Test func strictAcceptsValidDefiniteLengthV4() {
+ let token = makeV4Token(amounts: [1, 4, 16])
+ let info = CashuTokenDecoder.decode(token, strict: true)
+ #expect(info?.version == "B")
+ #expect(info?.amount == 21)
+ }
+
+ @Test func strictRejectsUnwalkableV4() {
+ // Valid base64, but not CBOR we can walk: permissive mode returns a
+ // generic chip, strict mode refuses it.
+ let token = "cashuB" + base64URL(Data([0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x01, 0x02]))
+ #expect(CashuTokenDecoder.decode(token)?.version == "B")
+ #expect(CashuTokenDecoder.decode(token, strict: true) == nil)
+ }
+
+ @Test func strictRejectsTruncatedV4() {
+ let token = makeV4Token(amounts: [1, 4, 16])
+ // Lop off the tail of the base64 payload — CBOR can no longer be walked.
+ let truncated = String(token.prefix(token.count - 12))
+ #expect(CashuTokenDecoder.decode(truncated, strict: true) == nil)
+ }
+
+ @Test func strictRejectsAmountlessToken() {
+ // A well-formed V3 token that carries no positive proof amount.
+ let json: [String: Any] = [
+ "token": [[
+ "mint": "https://mint.example.com",
+ "proofs": [["amount": 0, "id": "x", "secret": "s", "C": "c"] as [String: Any]]
+ ] as [String: Any]]
+ ]
+ let token = "cashuA" + base64URL(try! JSONSerialization.data(withJSONObject: json))
+ #expect(CashuTokenDecoder.decode(token)?.amount == nil)
+ #expect(CashuTokenDecoder.decode(token, strict: true) == nil)
+ }
+
+ // MARK: - URI Form and Normalization
+
+ @Test func uriFormsDecode() {
+ let token = makeV3Token(entries: [("https://mint.example.com", [7])])
+ for wrapped in ["cashu:\(token)", "cashu://\(token)", "CASHU:\(token)"] {
+ #expect(CashuTokenDecoder.bareToken(from: wrapped) == token, "failed for \(wrapped)")
+ #expect(CashuTokenDecoder.decode(wrapped)?.amount == 7)
+ }
+ }
+
+ @Test func percentEncodedURIDecodes() {
+ let token = makeV3Token(entries: [("https://mint.example.com", [7])])
+ let encoded = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
+ #expect(CashuTokenDecoder.decode("cashu:\(encoded)")?.amount == 7)
+ }
+
+ @Test func bareTokenRejectsNonTokens() {
+ #expect(CashuTokenDecoder.bareToken(from: "hello world") == nil)
+ #expect(CashuTokenDecoder.bareToken(from: "cashuC" + String(repeating: "a", count: 50)) == nil)
+ #expect(CashuTokenDecoder.bareToken(from: "cashuA{not-base64!}") == nil)
+ #expect(CashuTokenDecoder.bareToken(from: "cashuA") == nil) // too short
+ }
+
+ // MARK: - Adversarial Input (never crash, fail closed)
+
+ @Test func truncatedTokensNeverCrash() {
+ let v3 = makeV3Token(entries: [("https://mint.example.com", [1, 2, 4, 8])], memo: "memo")
+ let v4 = makeV4Token(memo: "memo", amounts: [1, 2, 4, 8])
+ for token in [v3, v4] {
+ for length in stride(from: 0, to: token.count, by: 3) {
+ _ = CashuTokenDecoder.decode(String(token.prefix(length)))
+ }
+ }
+ // Truncating the payload must not produce a phantom V3 summary
+ #expect(CashuTokenDecoder.decode(String(v3.prefix(v3.count - 10))) == nil)
+ }
+
+ @Test func garbagePayloadsNeverCrash() {
+ var rng = SystemRandomNumberGenerator()
+ for _ in 0..<200 {
+ let length = Int.random(in: 0..<600, using: &rng)
+ let junk = Data((0.. [String] { [] }
diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift
index 6ed7c6da..25aa8300 100644
--- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift
+++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift
@@ -192,6 +192,9 @@ struct ChatOutgoingCoordinatorContextTests {
context.isTeleported = true
coordinator.sendMessage("hello geo")
+ // Geohash sends mine a NIP-13 nonce tag off-main before echoing and
+ // sending; await the send task, then drain the main queue.
+ await coordinator.geohashMiningTask?.value
await drainMainActorTasks()
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
@@ -215,4 +218,35 @@ struct ChatOutgoingCoordinatorContextTests {
#expect(context.appendedPublicMessages.count == 1)
#expect(context.sentGeohashContexts.count == 1)
}
+
+ @Test @MainActor
+ func sendMessage_onLocationChannel_serializesRapidSendsInSendOrder() async {
+ let context = MockChatOutgoingContext()
+ let coordinator = ChatOutgoingCoordinator(context: context)
+ let channel = GeohashChannel(level: .city, geohash: "u4pruydq")
+ context.activeChannel = .location(channel)
+
+ // Two back-to-back sends. The first carries much larger content, so
+ // its NIP-13 mining hashes a bigger event per attempt and runs longer
+ // than the second's. Without serialization the second (faster) task
+ // could finish first and reorder both the local timeline and the
+ // relayed events. The coordinator chains the mining tasks — each send
+ // awaits the previous send's task before it echoes and relays — so the
+ // visible order must always match the send order.
+ let first = "first " + String(repeating: "x", count: 4000)
+ let second = "second"
+ coordinator.sendMessage(first)
+ coordinator.sendMessage(second)
+
+ // The stored task is the second send, which awaits the first.
+ await coordinator.geohashMiningTask?.value
+ await drainMainActorTasks()
+
+ // Local echoes land in send order…
+ #expect(context.appendedPublicMessages.map(\.message.content) == [first, second])
+ // …and so do the relayed events (IDs match the echoes 1:1, in order).
+ #expect(context.sentGeohashContexts.count == 2)
+ #expect(context.sentGeohashContexts.map(\.event.id)
+ == context.appendedPublicMessages.map(\.message.id))
+ }
}
diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift
index 9bcd2b93..be76fe82 100644
--- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift
+++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift
@@ -186,7 +186,7 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
// Inbound public message processing
var blockedMessageIDs: Set = []
var rateLimitAllowed = true
- private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = []
+ private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = []
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
var stablePeerIDs: [PeerID: PeerID] = [:]
@@ -199,8 +199,8 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon
blockedMessageIDs.contains(message.id)
}
- func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
- rateLimitChecks.append((senderKey, contentKey))
+ func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
+ rateLimitChecks.append((senderKey, contentKey, powBits))
return rateLimitAllowed
}
diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift
index 8ffd2933..b0d39782 100644
--- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift
+++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift
@@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
// Routing & acknowledgements
private(set) var flushedOutboxPeerIDs: [PeerID] = []
+ private(set) var courierRetryPeerIDs: [PeerID] = []
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
+ func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) }
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshDeliveryAcks.append((messageID, peerID))
}
@@ -154,6 +156,24 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verifyResponsePayloads.append((peerID, payload))
}
+
+ // Group payloads
+ private(set) var groupInvitePayloads: [(peerID: PeerID, payload: Data)] = []
+ private(set) var groupKeyUpdatePayloads: [(peerID: PeerID, payload: Data)] = []
+
+ func handleGroupInvitePayload(from peerID: PeerID, payload: Data) {
+ groupInvitePayloads.append((peerID, payload))
+ }
+
+ func handleGroupKeyUpdatePayload(from peerID: PeerID, payload: Data) {
+ groupKeyUpdatePayloads.append((peerID, payload))
+ }
+
+ private(set) var vouchPayloads: [(peerID: PeerID, payload: Data)] = []
+
+ func handleVouchPayload(from peerID: PeerID, payload: Data) {
+ vouchPayloads.append((peerID, payload))
+ }
}
// MARK: - Helpers
diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift
index 4ef7f9f7..6034c890 100644
--- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift
+++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift
@@ -51,6 +51,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
func saveIdentityState() { saveIdentityStateCount += 1 }
+ private(set) var vouchToConnectedVerifiedPeersCount = 0
+ func vouchToConnectedVerifiedPeers() { vouchToConnectedVerifiedPeersCount += 1 }
+
// Encryption status
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var updatedEncryptionStatusPeers: [PeerID] = []
diff --git a/bitchatTests/ChatVouchCoordinatorContextTests.swift b/bitchatTests/ChatVouchCoordinatorContextTests.swift
new file mode 100644
index 00000000..18175576
--- /dev/null
+++ b/bitchatTests/ChatVouchCoordinatorContextTests.swift
@@ -0,0 +1,353 @@
+//
+// ChatVouchCoordinatorContextTests.swift
+// bitchatTests
+//
+// Exercises `ChatVouchCoordinator` against a mock `ChatVouchContext` —
+// proving the exchange policy (verified + capable peers only, batch cap,
+// 24h rate limit) and the accept policy (verified senders only, real
+// Ed25519 signature verification, expiry) without a `ChatViewModel`.
+// Storage-level gates (self-vouch, already-verified vouchee, per-vouchee
+// cap) are covered by `SecureIdentityStateManagerVouchTests`.
+//
+
+import CryptoKit
+import Foundation
+import BitFoundation
+import Testing
+
+@testable import bitchat
+
+// MARK: - Mock Context
+
+@MainActor
+private final class MockChatVouchContext: ChatVouchContext {
+ // Identity & trust state
+ var fingerprintsByPeerID: [PeerID: String] = [:]
+ var verifiedFingerprints: Set = []
+ var signingKeysByFingerprint: [String: Data] = [:]
+ var recentVerified: [String] = []
+ private(set) var recentVerifiedRequests: [(limit: Int, excluding: String)] = []
+ private(set) var recordedVouches: [(vouchee: String, voucher: String, timestamp: Date)] = []
+ var recordVouchResult = true
+ var lastBatchSentAt: [String: Date] = [:]
+ private(set) var markedBatchSent: [(fingerprint: String, date: Date)] = []
+
+ func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
+ func isVerifiedFingerprint(_ fingerprint: String) -> Bool { verifiedFingerprints.contains(fingerprint) }
+ func signingKey(forFingerprint fingerprint: String) -> Data? { signingKeysByFingerprint[fingerprint] }
+
+ func recentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
+ recentVerifiedRequests.append((limit, fingerprint))
+ return Array(recentVerified.filter { $0 != fingerprint }.prefix(limit))
+ }
+
+ @discardableResult
+ func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
+ recordedVouches.append((voucheeFingerprint, voucherFingerprint, timestamp))
+ return recordVouchResult
+ }
+
+ func lastVouchBatchSent(to fingerprint: String) -> Date? { lastBatchSentAt[fingerprint] }
+
+ func markVouchBatchSent(to fingerprint: String, at date: Date) {
+ markedBatchSent.append((fingerprint, date))
+ lastBatchSentAt[fingerprint] = date
+ }
+
+ // Transport
+ var capabilitiesByPeerID: [PeerID: PeerCapabilities] = [:]
+ var mySigningKey = Curve25519.Signing.PrivateKey()
+ private(set) var installedObservers: [(PeerID, String) -> Void] = []
+ private(set) var sentVouchPayloads: [(payload: Data, peerID: PeerID)] = []
+
+ var connectedPeerIDList: [PeerID] = []
+
+ func peerCapabilities(for peerID: PeerID) -> PeerCapabilities { capabilitiesByPeerID[peerID] ?? [] }
+
+ func connectedPeerIDs() -> [PeerID] { connectedPeerIDList }
+
+ func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {
+ installedObservers.append(handler)
+ }
+
+ func noiseSignData(_ data: Data) -> Data? { try? mySigningKey.signature(for: data) }
+
+ func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {
+ sentVouchPayloads.append((payload, peerID))
+ }
+
+ // UI refresh
+ private(set) var trustChangedCount = 0
+
+ func notifyPeerTrustChanged() { trustChangedCount += 1 }
+}
+
+// MARK: - Tests
+
+struct ChatVouchCoordinatorContextTests {
+ private let peerID = PeerID(str: "1122334455667788")
+ private let peerFingerprint = String(repeating: "0f", count: 32)
+
+ @MainActor
+ private func makeVerifiedCapablePeer() -> (MockChatVouchContext, ChatVouchCoordinator) {
+ let context = MockChatVouchContext()
+ let coordinator = ChatVouchCoordinator(context: context)
+ context.fingerprintsByPeerID[peerID] = peerFingerprint
+ context.verifiedFingerprints.insert(peerFingerprint)
+ context.capabilitiesByPeerID[peerID] = [.vouch]
+ return (context, coordinator)
+ }
+
+ // MARK: Exchange policy
+
+ @Test @MainActor
+ func peerAuthenticated_sendsBatchForVerifiedCapablePeer() throws {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ let vouchees = [String(repeating: "01", count: 32), String(repeating: "02", count: 32)]
+ context.recentVerified = vouchees
+ for vouchee in vouchees {
+ context.signingKeysByFingerprint[vouchee] = Data(repeating: 0x33, count: 32)
+ }
+
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
+
+ // Candidates are requested most-recent-first, excluding the target.
+ #expect(context.recentVerifiedRequests.count == 1)
+ #expect(context.recentVerifiedRequests.first?.limit == VouchAttestation.maxBatchCount)
+ #expect(context.recentVerifiedRequests.first?.excluding == peerFingerprint)
+
+ let sent = try #require(context.sentVouchPayloads.first)
+ #expect(sent.peerID == peerID)
+ let attestations = VouchAttestation.decodeList(from: sent.payload)
+ #expect(attestations.map(\.voucheeFingerprintHex) == vouchees)
+ // Every attestation carries a valid signature under our signing key.
+ let myPublicKey = context.mySigningKey.publicKey.rawRepresentation
+ #expect(attestations.allSatisfy { $0.verifySignature(voucherSigningKey: myPublicKey) })
+
+ // The rate limit is stamped only after an actual send.
+ #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
+ }
+
+ @Test @MainActor
+ func peerAuthenticated_requiresVerificationAndCapability() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ context.recentVerified = [String(repeating: "01", count: 32)]
+ context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
+
+ // Not verified by me: nothing.
+ context.verifiedFingerprints.remove(peerFingerprint)
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
+ #expect(context.sentVouchPayloads.isEmpty)
+
+ // Verified but advertises a non-empty capability set lacking .vouch:
+ // nothing. (An *empty*/unknown set is race-tolerant and still sends —
+ // see `attemptVouch_sendsWhenCapabilitiesUnknown`.)
+ context.verifiedFingerprints.insert(peerFingerprint)
+ context.capabilitiesByPeerID[peerID] = [.prekeys]
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
+ #expect(context.sentVouchPayloads.isEmpty)
+ #expect(context.markedBatchSent.isEmpty)
+ }
+
+ // MARK: Capability race tolerance & new triggers
+
+ @Test @MainActor
+ func attemptVouch_sendsWhenCapabilitiesUnknown() {
+ // Capability set still empty at attempt time (the peer's .vouch bit
+ // arrives on a later announce): the batch must still go out.
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ context.capabilitiesByPeerID[peerID] = []
+ context.recentVerified = [String(repeating: "01", count: 32)]
+ context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
+
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
+ #expect(context.sentVouchPayloads.count == 1)
+ #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
+ }
+
+ @Test @MainActor
+ func vouchToConnectedVerifiedPeers_sendsToConnectedVerifiedCapablePeer() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ context.connectedPeerIDList = [peerID]
+ context.recentVerified = [String(repeating: "01", count: 32)]
+ context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
+
+ // Session is already up (no peerAuthenticated re-fire); the verify pass
+ // is what makes the batch go out.
+ coordinator.vouchToConnectedVerifiedPeers()
+ let sent = context.sentVouchPayloads
+ #expect(sent.count == 1)
+ #expect(sent.first?.peerID == peerID)
+ #expect(context.markedBatchSent.map(\.fingerprint) == [peerFingerprint])
+ }
+
+ @Test @MainActor
+ func vouchToConnectedVerifiedPeers_skipsUnverifiedConnectedPeers() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ context.connectedPeerIDList = [peerID]
+ context.verifiedFingerprints.remove(peerFingerprint)
+ context.recentVerified = [String(repeating: "01", count: 32)]
+ context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
+
+ coordinator.vouchToConnectedVerifiedPeers()
+ #expect(context.sentVouchPayloads.isEmpty)
+ }
+
+ @Test @MainActor
+ func peersUpdated_sendsOnceCapabilityBearingAnnounceArrives() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ context.recentVerified = [String(repeating: "01", count: 32)]
+ context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
+
+ // First announce before the .vouch bit is known: empty set is
+ // race-tolerant, so it already sends and stamps the throttle.
+ context.capabilitiesByPeerID[peerID] = []
+ coordinator.peersUpdated([peerID])
+ #expect(context.sentVouchPayloads.count == 1)
+
+ // A later announce carrying .vouch must not double-send (throttled).
+ context.capabilitiesByPeerID[peerID] = [.vouch]
+ coordinator.peersUpdated([peerID])
+ #expect(context.sentVouchPayloads.count == 1)
+ }
+
+ @Test @MainActor
+ func peerAuthenticated_rateLimitsPerPeerPer24Hours() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ context.recentVerified = [String(repeating: "01", count: 32)]
+ context.signingKeysByFingerprint[context.recentVerified[0]] = Data(repeating: 0x33, count: 32)
+
+ let now = Date()
+ context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-60 * 60)
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
+ #expect(context.sentVouchPayloads.isEmpty)
+
+ // Once the interval has elapsed the batch goes out again.
+ context.lastBatchSentAt[peerFingerprint] = now.addingTimeInterval(-ChatVouchCoordinator.batchInterval - 1)
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint, now: now)
+ #expect(context.sentVouchPayloads.count == 1)
+ }
+
+ @Test @MainActor
+ func peerAuthenticated_skipsCandidatesWithoutSigningKeysAndEmptyBatches() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ let withKey = String(repeating: "01", count: 32)
+ let withoutKey = String(repeating: "02", count: 32)
+ context.recentVerified = [withoutKey, withKey]
+ context.signingKeysByFingerprint[withKey] = Data(repeating: 0x33, count: 32)
+
+ coordinator.peerAuthenticated(peerID, fingerprint: peerFingerprint)
+ let attestations = VouchAttestation.decodeList(from: context.sentVouchPayloads[0].payload)
+ #expect(attestations.map(\.voucheeFingerprintHex) == [withKey])
+
+ // No signable candidates at all: nothing is sent or rate-stamped.
+ let freshPeer = PeerID(str: "aabbccddeeff0011")
+ let freshFingerprint = String(repeating: "0e", count: 32)
+ context.fingerprintsByPeerID[freshPeer] = freshFingerprint
+ context.verifiedFingerprints.insert(freshFingerprint)
+ context.capabilitiesByPeerID[freshPeer] = [.vouch]
+ context.recentVerified = [withoutKey]
+ coordinator.peerAuthenticated(freshPeer, fingerprint: freshFingerprint)
+ #expect(context.sentVouchPayloads.count == 1)
+ #expect(!context.markedBatchSent.contains { $0.fingerprint == freshFingerprint })
+ }
+
+ // MARK: Accept policy
+
+ @MainActor
+ private func makeInboundBatch(
+ signedBy key: Curve25519.Signing.PrivateKey,
+ vouchee: String = String(repeating: "07", count: 32),
+ timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
+ ) throws -> Data {
+ let voucheeData = try #require(Data(hexString: vouchee))
+ let attestation = try #require(VouchAttestation.build(
+ voucheeFingerprint: voucheeData,
+ voucheeSigningKey: Data(repeating: 0x44, count: 32),
+ timestampMs: timestampMs,
+ sign: { try? key.signature(for: $0) }
+ ))
+ return try #require(VouchAttestation.encodeList([attestation]))
+ }
+
+ @Test @MainActor
+ func handleVouchPayload_acceptsValidVouchFromVerifiedSender() throws {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ let senderKey = Curve25519.Signing.PrivateKey()
+ context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
+
+ let vouchee = String(repeating: "07", count: 32)
+ let payload = try makeInboundBatch(signedBy: senderKey, vouchee: vouchee)
+ coordinator.handleVouchPayload(from: peerID, payload: payload)
+
+ #expect(context.recordedVouches.count == 1)
+ #expect(context.recordedVouches.first?.vouchee == vouchee)
+ #expect(context.recordedVouches.first?.voucher == peerFingerprint)
+ #expect(context.trustChangedCount == 1)
+ }
+
+ @Test @MainActor
+ func handleVouchPayload_rejectsUnverifiedOrUnknownSender() throws {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ let senderKey = Curve25519.Signing.PrivateKey()
+ context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
+ let payload = try makeInboundBatch(signedBy: senderKey)
+
+ // Sender's fingerprint is not in my verified set.
+ context.verifiedFingerprints.remove(peerFingerprint)
+ coordinator.handleVouchPayload(from: peerID, payload: payload)
+ #expect(context.recordedVouches.isEmpty)
+
+ // Unknown peer entirely.
+ coordinator.handleVouchPayload(from: PeerID(str: "ffeeddccbbaa9988"), payload: payload)
+ #expect(context.recordedVouches.isEmpty)
+ #expect(context.trustChangedCount == 0)
+ }
+
+ @Test @MainActor
+ func handleVouchPayload_rejectsForgedSignaturesAndExpiredAttestations() throws {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ let senderKey = Curve25519.Signing.PrivateKey()
+ context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
+
+ // Signed by an imposter key: signature check against the sender's
+ // announce-bound key fails.
+ let imposter = Curve25519.Signing.PrivateKey()
+ let forged = try makeInboundBatch(signedBy: imposter)
+ coordinator.handleVouchPayload(from: peerID, payload: forged)
+ #expect(context.recordedVouches.isEmpty)
+
+ // Correctly signed but expired.
+ let staleMs = UInt64(Date().addingTimeInterval(-31 * 24 * 60 * 60).timeIntervalSince1970 * 1000)
+ let expired = try makeInboundBatch(signedBy: senderKey, timestampMs: staleMs)
+ coordinator.handleVouchPayload(from: peerID, payload: expired)
+ #expect(context.recordedVouches.isEmpty)
+ #expect(context.trustChangedCount == 0)
+
+ // No signing key known for the sender: batch dropped.
+ context.signingKeysByFingerprint.removeValue(forKey: peerFingerprint)
+ let valid = try makeInboundBatch(signedBy: senderKey)
+ coordinator.handleVouchPayload(from: peerID, payload: valid)
+ #expect(context.recordedVouches.isEmpty)
+ }
+
+ @Test @MainActor
+ func handleVouchPayload_skipsUIRefreshWhenNothingStored() throws {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ let senderKey = Curve25519.Signing.PrivateKey()
+ context.signingKeysByFingerprint[peerFingerprint] = senderKey.publicKey.rawRepresentation
+ context.recordVouchResult = false // e.g. self-vouch dropped by the store
+
+ let payload = try makeInboundBatch(signedBy: senderKey)
+ coordinator.handleVouchPayload(from: peerID, payload: payload)
+ #expect(context.recordedVouches.count == 1)
+ #expect(context.trustChangedCount == 0)
+ }
+
+ @Test @MainActor
+ func setupNoiseCallbacks_installsAdditiveObserver() {
+ let (context, coordinator) = makeVerifiedCapablePeer()
+ coordinator.setupNoiseCallbacks()
+ #expect(context.installedObservers.count == 1)
+ }
+}
diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift
index 99bed2b5..3da82140 100644
--- a/bitchatTests/CommandProcessorTests.swift
+++ b/bitchatTests/CommandProcessorTests.swift
@@ -370,6 +370,173 @@ struct CommandProcessorTests {
}
}
+ // MARK: - /pay
+
+ @MainActor
+ @Test func payWithoutArgumentsPrintsUsage() {
+ let processor = makePayProcessor(context: MockCommandContextProvider())
+ switch processor.process("/pay") {
+ case .success(let message):
+ #expect(message?.contains("usage: /pay") == true)
+ default:
+ Issue.record("Expected success (usage) result")
+ }
+ }
+
+ @MainActor
+ @Test func payRejectsInvalidToken() {
+ let context = MockCommandContextProvider()
+ let processor = makePayProcessor(context: context)
+ for bad in ["/pay nonsense", "/pay cashuAshort", "/pay cashuA!!!!!!!!!!!!!!!!"] {
+ switch processor.process(bad) {
+ case .error:
+ break
+ default:
+ Issue.record("Expected error for \(bad)")
+ }
+ }
+ #expect(context.sentPrivateMessages.isEmpty)
+ #expect(context.sentPublicMessages.isEmpty)
+ }
+
+ @MainActor
+ @Test func paySendsBareTokenInPrivateChat() {
+ let context = MockCommandContextProvider()
+ let peerID = PeerID(str: "abcd1234abcd1234")
+ context.selectedPrivateChatPeer = peerID
+ let processor = makePayProcessor(context: context)
+
+ // cashu: URI form must be normalized to the bare token before sending
+ switch processor.process("/pay cashu:\(Self.validV3Token)") {
+ case .success(let message):
+ #expect(message?.contains("21 sat") == true)
+ default:
+ Issue.record("Expected success result")
+ }
+ #expect(context.sentPrivateMessages.count == 1)
+ #expect(context.sentPrivateMessages.first?.content == Self.validV3Token)
+ #expect(context.sentPrivateMessages.first?.peerID == peerID)
+ #expect(context.sentPublicMessages.isEmpty)
+ }
+
+ @MainActor
+ @Test func payInPublicChannelRequiresExplicitConfirm() {
+ let context = MockCommandContextProvider()
+ let processor = makePayProcessor(context: context)
+
+ switch processor.process("/pay \(Self.validV3Token)") {
+ case .error(let message):
+ #expect(message.contains("public") == true)
+ default:
+ Issue.record("Expected error without confirm")
+ }
+ #expect(context.sentPublicMessages.isEmpty)
+
+ switch processor.process("/pay \(Self.validV3Token) public") {
+ case .success:
+ break
+ default:
+ Issue.record("Expected success with confirm")
+ }
+ #expect(context.sentPublicMessages == [Self.validV3Token])
+ #expect(context.sentPrivateMessages.isEmpty)
+ }
+
+ @MainActor
+ @Test func payRejectsTruncatedOrJunkV4Token() {
+ let context = MockCommandContextProvider()
+ context.selectedPrivateChatPeer = PeerID(str: "abcd1234abcd1234")
+ let processor = makePayProcessor(context: context)
+
+ // Truncated V4 (definite-length CBOR can no longer be walked) and
+ // pure base64 junk under the cashuB prefix must both be refused.
+ let truncatedV4 = String(Self.validV4Token.prefix(Self.validV4Token.count - 12))
+ let junkV4 = "cashuB" + String(repeating: "Q", count: 40)
+ for bad in ["/pay \(truncatedV4)", "/pay \(junkV4)"] {
+ switch processor.process(bad) {
+ case .error(let message):
+ #expect(message.contains("invalid cashu token") == true)
+ default:
+ Issue.record("Expected error for \(bad)")
+ }
+ }
+ #expect(context.sentPrivateMessages.isEmpty)
+ #expect(context.sentPublicMessages.isEmpty)
+ }
+
+ @MainActor
+ @Test func paySendsValidDefiniteLengthV4Token() {
+ let context = MockCommandContextProvider()
+ let peerID = PeerID(str: "abcd1234abcd1234")
+ context.selectedPrivateChatPeer = peerID
+ let processor = makePayProcessor(context: context)
+
+ switch processor.process("/pay \(Self.validV4Token)") {
+ case .success(let message):
+ #expect(message?.contains("21 sat") == true)
+ default:
+ Issue.record("Expected success result for valid V4 token")
+ }
+ #expect(context.sentPrivateMessages.count == 1)
+ #expect(context.sentPrivateMessages.first?.content == Self.validV4Token)
+ }
+
+ /// 21-sat single-mint V3 token (proofs of 1+4+16).
+ private static let validV3Token: String = {
+ let json: [String: Any] = [
+ "token": [[
+ "mint": "https://mint.example.com",
+ "proofs": [1, 4, 16].map { ["amount": $0, "id": "009a1f293253e41e", "secret": "s", "C": "02c"] }
+ ]],
+ "unit": "sat"
+ ]
+ let data = try! JSONSerialization.data(withJSONObject: json)
+ let b64 = data.base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ return "cashuA" + b64
+ }()
+
+ /// 21-sat single-mint definite-length V4 (CBOR) token (proofs of 1+4+16).
+ private static let validV4Token: String = {
+ func head(_ major: UInt8, _ value: UInt64) -> [UInt8] {
+ switch value {
+ case 0...23: return [(major << 5) | UInt8(value)]
+ case 24...0xFF: return [(major << 5) | 24, UInt8(value)]
+ default: return [(major << 5) | 25, UInt8(value >> 8), UInt8(value & 0xFF)]
+ }
+ }
+ func text(_ s: String) -> [UInt8] { head(3, UInt64(s.utf8.count)) + Array(s.utf8) }
+ func bytes(_ b: [UInt8]) -> [UInt8] { head(2, UInt64(b.count)) + b }
+ func uint(_ v: UInt64) -> [UInt8] { head(0, v) }
+ func array(_ items: [[UInt8]]) -> [UInt8] { head(4, UInt64(items.count)) + items.flatMap { $0 } }
+ func map(_ pairs: [(String, [UInt8])]) -> [UInt8] { head(5, UInt64(pairs.count)) + pairs.flatMap { text($0.0) + $0.1 } }
+
+ let proofs = [UInt64(1), 4, 16].map { amount in
+ map([("a", uint(amount)), ("s", text("secret")), ("c", bytes([0x02, 0xAB, 0xCD]))])
+ }
+ let cbor = map([
+ ("m", text("https://mint.example.com")),
+ ("u", text("sat")),
+ ("t", array([map([("i", bytes([0x00, 0xAD, 0x26, 0x8C])), ("p", array(proofs))])]))
+ ])
+ let b64 = Data(cbor).base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ return "cashuB" + b64
+ }()
+
+ @MainActor
+ private func makePayProcessor(context: MockCommandContextProvider) -> CommandProcessor {
+ CommandProcessor(
+ contextProvider: context,
+ meshService: MockTransport(),
+ identityManager: MockIdentityManager(MockKeychain())
+ )
+ }
+
@MainActor
private func withSelectedChannel(
_ channel: ChannelID,
@@ -435,6 +602,8 @@ private final class MockCommandContextProvider: CommandContextProvider {
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
+ private(set) var commandOutputs: [String] = []
+ private(set) var commandOutputDestinations: [CommandOutputDestination] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
@@ -477,6 +646,11 @@ private final class MockCommandContextProvider: CommandContextProvider {
sentPublicRawMessages.append(content)
}
+ private(set) var sentPublicMessages: [String] = []
+ func sendPublicMessage(_ content: String) {
+ sentPublicMessages.append(content)
+ }
+
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
@@ -485,6 +659,18 @@ private final class MockCommandContextProvider: CommandContextProvider {
publicSystemMessages.append(content)
}
+ func currentCommandDestination() -> CommandOutputDestination {
+ if let peerID = selectedPrivateChatPeer {
+ return .privateChat(peerID)
+ }
+ return .meshTimeline
+ }
+
+ func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
+ commandOutputs.append(content)
+ commandOutputDestinations.append(destination)
+ }
+
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
@@ -492,4 +678,32 @@ private final class MockCommandContextProvider: CommandContextProvider {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
+
+ // Groups: record the parsed subcommand + argument the processor forwarded.
+ private(set) var groupCommands: [(subcommand: String, argument: String)] = []
+
+ func groupCreate(named name: String) -> CommandResult {
+ groupCommands.append(("create", name))
+ return .handled
+ }
+
+ func groupInvite(nickname: String) -> CommandResult {
+ groupCommands.append(("invite", nickname))
+ return .handled
+ }
+
+ func groupRemove(nickname: String) -> CommandResult {
+ groupCommands.append(("remove", nickname))
+ return .handled
+ }
+
+ func groupLeave() -> CommandResult {
+ groupCommands.append(("leave", ""))
+ return .handled
+ }
+
+ func groupList() -> CommandResult {
+ groupCommands.append(("list", ""))
+ return .handled
+ }
}
diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift
index 9e675649..23829b0e 100644
--- a/bitchatTests/CourierStoreTests.swift
+++ b/bitchatTests/CourierStoreTests.swift
@@ -103,7 +103,7 @@ struct CourierStoreTests {
@Test func perDepositorQuota() {
let store = makeStore()
- for _ in 0.. give 1, keep 1).
+ let courierY = Data(repeating: 0xC2, count: 32)
+ let sprayedToY = store.takeSprayCopies(for: courierY)
+ #expect(sprayedToY.count == 1)
+ #expect(sprayedToY.first?.copies == 1)
+
+ // Budget exhausted (carry-only): nothing left to spray.
+ #expect(store.takeSprayCopies(for: Data(repeating: 0xC3, count: 32)).isEmpty)
+ // The carried original is still deliverable.
+ #expect(store.takeEnvelopes(for: recipientKey).count == 1)
+ }
+
+ @Test func carryOnlyEnvelopesAreNeverSprayed() {
+ let store = makeStore()
+ #expect(store.deposit(makeEnvelope(), from: depositorA))
+ #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty)
+ }
+
+ @Test func duplicateDepositKeepsLargerSprayBudget() {
+ let store = makeStore()
+ let recipientKey = Data(repeating: 0xB0, count: 32)
+ let ciphertext = Data(repeating: 0x42, count: 96)
+ let carryOnly = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext)
+ #expect(store.deposit(carryOnly, from: depositorA))
+ #expect(store.deposit(carryOnly.withCopies(4), from: depositorB))
+
+ let sprayed = store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32))
+ #expect(sprayed.first?.copies == 2)
+ }
+
+ // MARK: - Remote handover (relayed announces)
+
+ @Test func remoteHandoverIsNonDestructiveAndCooledDown() {
+ let store = makeStore()
+ let recipientKey = Data(repeating: 0xB0, count: 32)
+ let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4)
+ #expect(store.deposit(envelope, from: depositorA))
+
+ let first = store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600)
+ #expect(first.count == 1)
+ // The flooded copy carries no spray budget.
+ #expect(first.first?.copies == 1)
+ // Non-destructive: the envelope is still carried...
+ #expect(!store.isEmpty)
+ // ...and inside the cooldown it is not re-flooded.
+ #expect(store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600).isEmpty)
+ // A direct encounter still hands it over destructively.
+ #expect(store.takeEnvelopes(for: recipientKey).count == 1)
+ #expect(store.isEmpty)
+ }
+
+ // MARK: - Legacy persistence
+
+ @Test func legacyPersistedFileLoadsAsFavoriteCarryOnly() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("courier-legacy-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ // Envelope persisted by a pre-tier/pre-spray build: no tier, copies,
+ // or spray bookkeeping fields.
+ let recipientKey = Data(repeating: 0xB0, count: 32)
+ let envelope = makeEnvelope(recipientKey: recipientKey)
+ let legacy: [[String: Any]] = [[
+ "recipientTag": envelope.recipientTag.base64EncodedString(),
+ "expiry": envelope.expiry,
+ "ciphertext": envelope.ciphertext.base64EncodedString(),
+ "depositorNoiseKey": depositorA.base64EncodedString(),
+ "storedAt": Self.baseDate.timeIntervalSinceReferenceDate
+ ]]
+ let data = try JSONSerialization.data(withJSONObject: legacy)
+ try data.write(to: fileURL)
+
+ let store = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
+ // Carry-only, so never sprayed...
+ #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty)
+ // ...but still delivered on encounter.
+ #expect(store.takeEnvelopes(for: recipientKey).count == 1)
+ }
}
diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift
index a8cc3079..22e56c79 100644
--- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift
+++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift
@@ -104,7 +104,7 @@ struct CourierEndToEndTests {
let bob = makeService()
// Alice and Carol are mutual favorites; trust policy is exercised
// separately in depositFromUntrustedPeerIsRejected.
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
@@ -134,7 +134,7 @@ struct CourierEndToEndTests {
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -186,7 +186,7 @@ struct CourierEndToEndTests {
let carol = makeService()
let bobIdentity = MockIdentityManager(MockKeychain())
let bob = makeService(identityManager: bobIdentity)
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
@@ -215,7 +215,7 @@ struct CourierEndToEndTests {
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -254,7 +254,7 @@ struct CourierEndToEndTests {
let alice = makeService()
let carol = makeService()
let bob = makeService()
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
@@ -278,7 +278,7 @@ struct CourierEndToEndTests {
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -312,11 +312,11 @@ struct CourierEndToEndTests {
#expect(carol.courierStore.isEmpty)
}
- @Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
+ @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
@@ -340,7 +340,7 @@ struct CourierEndToEndTests {
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -356,23 +356,24 @@ struct CourierEndToEndTests {
let directAnnounce = try #require(bobOut.first(ofType: .announce))
// A relayed copy has a decremented TTL but a still-valid signature
- // (TTL is excluded from announce signatures). Envelopes are removed
- // from the store optimistically, so handover must wait for a direct
- // encounter instead of chasing a multi-hop path.
+ // (TTL is excluded from announce signatures). The recipient is
+ // multi-hop away, so a copy floods toward them speculatively while
+ // the carried original stays put for a future direct encounter.
var relayedAnnounce = directAnnounce
relayedAnnounce.ttl = directAnnounce.ttl - 1
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
- let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
- { carolOut.count(ofType: .courierEnvelope) > 0 },
- timeout: TestConstants.shortTimeout
+ let remoteHandover = await TestHelpers.waitUntil(
+ { carolOut.count(ofType: .courierEnvelope) == 1 },
+ timeout: TestConstants.defaultTimeout
)
- #expect(!leakedOnRelayedAnnounce)
+ #expect(remoteHandover)
#expect(!carol.courierStore.isEmpty)
- // The relayed copy consumed the original announce's dedup key
- // (sender/timestamp/payload — TTL excluded), so the direct handover
- // needs a fresh announce. Wait out the 1s announce throttle first.
+ // A second relayed announce inside the cooldown must not re-flood
+ // the same envelope. The original announce's dedup key is consumed
+ // (sender/timestamp/payload — TTL excluded), so use a fresh announce;
+ // wait out the 1s announce throttle first.
try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
@@ -383,10 +384,32 @@ struct CourierEndToEndTests {
let freshAnnounce = try #require(
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
)
- carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
+ var relayedFreshAnnounce = freshAnnounce
+ relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1
+ carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let refloodedInCooldown = await TestHelpers.waitUntil(
+ { carolOut.count(ofType: .courierEnvelope) > 1 },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!refloodedInCooldown)
+ #expect(!carol.courierStore.isEmpty)
+
+ // A later *direct* announce still performs the destructive handover.
+ try await Task.sleep(nanoseconds: 1_100_000_000)
+ bob.sendBroadcastAnnounce()
+ let announcedAgain = await TestHelpers.waitUntil(
+ { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(announcedAgain)
+ let directAgain = try #require(
+ bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp }
+ )
+ carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
- { carolOut.count(ofType: .courierEnvelope) == 1 },
+ { carolOut.count(ofType: .courierEnvelope) == 2 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
@@ -417,7 +440,7 @@ struct CourierEndToEndTests {
@Test func depositFromUntrustedPeerIsRejected() async throws {
let carol = makeService()
- carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
+ carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
@@ -433,6 +456,45 @@ struct CourierEndToEndTests {
ciphertext: sealed
)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
+ let unsigned = BitchatPacket(
+ type: MessageType.courierEnvelope.rawValue,
+ senderID: Data(hexString: alicePeerID.id) ?? Data(),
+ recipientID: Data(hexString: carol.myPeerID.id),
+ timestamp: UInt64(now.timeIntervalSince1970 * 1000),
+ payload: try #require(envelope.encode()),
+ signature: nil,
+ ttl: 1
+ )
+ let packet = try #require(alice.signPacket(unsigned))
+
+ carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
+ let stored = await TestHelpers.waitUntil(
+ { !carol.courierStore.isEmpty },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!stored)
+ }
+
+ @Test func unsignedDepositIsRejected() async throws {
+ let carol = makeService()
+ carol.courierDepositPolicy = { _, _ in .favorite }
+
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
+ let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned"))
+ let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
+ let now = Date()
+ let envelope = CourierEnvelope(
+ recipientTag: CourierEnvelope.recipientTag(
+ noiseStaticKey: bobKey,
+ epochDay: CourierEnvelope.epochDay(for: now)
+ ),
+ expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
+ ciphertext: sealed
+ )
+ let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
+ // Correct sender, willing policy — but no packet signature: the
+ // courier cannot authenticate the depositor, so it must not carry.
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
@@ -443,7 +505,7 @@ struct CourierEndToEndTests {
ttl: 1
)
- carol._test_handlePacket(packet, fromPeerID: alicePeerID)
+ carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
@@ -459,8 +521,8 @@ struct CourierEndToEndTests {
preseedConnectedPeer(mallory, in: carol)
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
- carol.courierDepositPolicy = { depositorKey in
- depositorKey == trustedAliceKey
+ carol.courierDepositPolicy = { depositorKey, _ in
+ depositorKey == trustedAliceKey ? .favorite : nil
}
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
diff --git a/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift
new file mode 100644
index 00000000..456932a7
--- /dev/null
+++ b/bitchatTests/EndToEnd/PrekeyEndToEndTests.swift
@@ -0,0 +1,399 @@
+//
+// PrekeyEndToEndTests.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import CoreBluetooth
+import BitFoundation
+@testable import bitchat
+
+/// Forward-secret courier flow through real BLEService instances: Bob gossips
+/// a signed prekey bundle, Alice verifies and caches it, seals to a one-time
+/// prekey instead of Bob's static key, Carol carries the opaque envelope, and
+/// Bob opens it with the matching prekey private.
+struct PrekeyEndToEndTests {
+
+ // MARK: - Helpers
+
+ private final class PacketTap {
+ private let lock = NSLock()
+ private var packets: [BitchatPacket] = []
+
+ func record(_ packet: BitchatPacket) {
+ lock.lock(); packets.append(packet); lock.unlock()
+ }
+
+ func first(ofType type: MessageType) -> BitchatPacket? {
+ lock.lock(); defer { lock.unlock() }
+ return packets.first { $0.type == type.rawValue }
+ }
+ }
+
+ private final class NoiseCaptureDelegate: BitchatDelegate {
+ private let lock = NSLock()
+ private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
+
+ func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
+ lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
+ }
+
+ func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
+ lock.lock(); defer { lock.unlock() }
+ return payloads
+ }
+
+ // Unused BitchatDelegate requirements.
+ func didReceiveMessage(_ message: BitchatMessage) {}
+ func didConnectToPeer(_ peerID: PeerID) {}
+ func didDisconnectFromPeer(_ peerID: PeerID) {}
+ func didUpdatePeerList(_ peers: [PeerID]) {}
+ func didUpdateBluetoothState(_ state: CBManagerState) {}
+ func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
+ }
+
+ private func makeService() -> BLEService {
+ let keychain = MockKeychain()
+ let service = BLEService(
+ keychain: keychain,
+ idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
+ identityManager: MockIdentityManager(keychain),
+ initializeBluetoothManagers: false
+ )
+ service.courierStore = CourierStore(persistsToDisk: false)
+ service.prekeyBundleStore = PrekeyBundleStore(persistsToDisk: false)
+ return service
+ }
+
+ private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
+ let packet = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data("ping".utf8),
+ signature: nil,
+ ttl: 1
+ )
+ service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
+ }
+
+ /// Broadcast announce + prekey bundle from `peer` and return both packets.
+ private func captureAnnounceAndBundle(from peer: BLEService, tap: PacketTap) async throws -> (announce: BitchatPacket, bundle: BitchatPacket) {
+ peer.sendBroadcastAnnounce()
+ let published = await TestHelpers.waitUntil(
+ { tap.first(ofType: .announce) != nil && tap.first(ofType: .prekeyBundle) != nil },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(published)
+ return (
+ announce: try #require(tap.first(ofType: .announce)),
+ bundle: try #require(tap.first(ofType: .prekeyBundle))
+ )
+ }
+
+ // MARK: - Tests
+
+ @Test func prekeySealedMailTravelsViaCourierAndOpens() async throws {
+ let alice = makeService()
+ let carol = makeService()
+ let bob = makeService()
+ carol.courierDepositPolicy = { _, _ in .favorite }
+
+ let bobDelegate = NoiseCaptureDelegate()
+ bob.delegate = bobDelegate
+
+ let aliceOut = PacketTap()
+ alice._test_onOutboundPacket = aliceOut.record
+ let carolOut = PacketTap()
+ carol._test_onOutboundPacket = carolOut.record
+ let bobOut = PacketTap()
+ bob._test_onOutboundPacket = bobOut.record
+
+ preseedConnectedPeer(carol, in: alice)
+
+ // 1. While Bob is still around, Alice hears his verified announce
+ // (binding his signing key) and his gossiped prekey bundle.
+ let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
+ alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
+ alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let cached = await TestHelpers.waitUntil(
+ { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(cached)
+
+ // 2. Bob goes dark; Alice seals for him and deposits with Carol.
+ // The envelope must be v2: sealed to a one-time prekey.
+ #expect(alice.sendCourierMessage(
+ "burn after reading",
+ messageID: "prekey-msg-1",
+ recipientNoiseKey: bob.noiseStaticPublicKeyData(),
+ via: [carol.myPeerID]
+ ))
+ let deposited = await TestHelpers.waitUntil(
+ { aliceOut.first(ofType: .courierEnvelope) != nil },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(deposited)
+ let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
+ let sealedEnvelope = try #require(CourierEnvelope.decode(depositPacket.payload))
+ #expect(sealedEnvelope.prekeyID != nil)
+
+ // 3. Carol carries it (opaque, prekey or not).
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
+ let carried = await TestHelpers.waitUntil(
+ { !carol.courierStore.isEmpty },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(carried)
+
+ // 4. Bob resurfaces near Carol → handover, and the v2 discriminator
+ // survives the store round-trip.
+ bob.sendBroadcastAnnounce()
+ let reannounced = await TestHelpers.waitUntil(
+ { bobOut.first(ofType: .announce) != nil },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(reannounced)
+ let handoverTrigger = try #require(bobOut.first(ofType: .announce))
+ carol._test_handlePacket(handoverTrigger, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let handedOver = await TestHelpers.waitUntil(
+ { carolOut.first(ofType: .courierEnvelope) != nil },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(handedOver)
+ let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
+ let handedEnvelope = try #require(CourierEnvelope.decode(handoverPacket.payload))
+ #expect(handedEnvelope.prekeyID == sealedEnvelope.prekeyID)
+
+ // 5. Bob opens it with the matching one-time prekey private and sees
+ // Alice as the authenticated sender.
+ bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
+ let received = await TestHelpers.waitUntil(
+ { !bobDelegate.snapshot().isEmpty },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(received)
+
+ let delivered = try #require(bobDelegate.snapshot().first)
+ #expect(delivered.type == .privateMessage)
+ #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
+ let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
+ #expect(message.messageID == "prekey-msg-1")
+ #expect(message.content == "burn after reading")
+
+ // 6. Redelivery tolerance: the same envelope arriving via another
+ // packet (spray-and-wait) still opens inside the grace window.
+ let redelivery = BitchatPacket(
+ type: MessageType.courierEnvelope.rawValue,
+ senderID: Data(hexString: carol.myPeerID.id) ?? Data(),
+ recipientID: handoverPacket.recipientID,
+ timestamp: handoverPacket.timestamp + 1,
+ payload: handoverPacket.payload,
+ signature: nil,
+ ttl: 1
+ )
+ bob._test_handlePacket(redelivery, fromPeerID: carol.myPeerID)
+ let redelivered = await TestHelpers.waitUntil(
+ { bobDelegate.snapshot().count == 2 },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(redelivered)
+ }
+
+ @Test func withoutBundleSealingFallsBackToStatic() async throws {
+ let alice = makeService()
+ let bob = makeService()
+
+ let bobDelegate = NoiseCaptureDelegate()
+ bob.delegate = bobDelegate
+ let aliceOut = PacketTap()
+ alice._test_onOutboundPacket = aliceOut.record
+
+ // Bob is a connected "courier" who happens to be the recipient: the
+ // envelope reaches him directly and the recipient tag matches.
+ preseedConnectedPeer(bob, in: alice)
+
+ // Alice never saw a bundle for Bob → v1 static-sealed envelope.
+ #expect(alice.sendCourierMessage(
+ "plain static seal",
+ messageID: "static-msg-1",
+ recipientNoiseKey: bob.noiseStaticPublicKeyData(),
+ via: [bob.myPeerID]
+ ))
+ let deposited = await TestHelpers.waitUntil(
+ { aliceOut.first(ofType: .courierEnvelope) != nil },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(deposited)
+ let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
+ let envelope = try #require(CourierEnvelope.decode(depositPacket.payload))
+ #expect(envelope.prekeyID == nil)
+
+ // Bob opens the v1 envelope exactly as before the prekey change.
+ // (No preseed: Alice is absent from Bob's mesh, so the sender should
+ // resolve to her full noise-key ID like the courier case.)
+ bob._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, preseedPeer: false)
+ let received = await TestHelpers.waitUntil(
+ { !bobDelegate.snapshot().isEmpty },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(received)
+ let delivered = try #require(bobDelegate.snapshot().first)
+ #expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
+ let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
+ #expect(message.content == "plain static seal")
+ }
+
+ @Test func unverifiableBundleIsIgnored() async throws {
+ let alice = makeService()
+ let bob = makeService()
+
+ let bobOut = PacketTap()
+ bob._test_onOutboundPacket = bobOut.record
+
+ // Alice receives Bob's bundle but never saw a verified announce, so
+ // no signing key is bound to his noise key: the bundle must not be
+ // cached or enter Alice's gossip store.
+ let (_, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
+ alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let cached = await TestHelpers.waitUntil(
+ { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!cached)
+ }
+
+ @Test func forgedBundleSignatureIsRejected() async throws {
+ let alice = makeService()
+ let bob = makeService()
+
+ let bobOut = PacketTap()
+ bob._test_onOutboundPacket = bobOut.record
+
+ let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
+ alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ // Mallory tampers with the gossiped bundle in flight.
+ let bundle = try #require(PrekeyBundle.decode(bundlePacket.payload))
+ var forgedSignature = bundle.signature
+ forgedSignature[0] ^= 0x01
+ let forged = PrekeyBundle(
+ noiseStaticPublicKey: bundle.noiseStaticPublicKey,
+ prekeys: bundle.prekeys,
+ generatedAt: bundle.generatedAt,
+ signature: forgedSignature
+ )
+ let forgedPacket = BitchatPacket(
+ type: MessageType.prekeyBundle.rawValue,
+ senderID: bundlePacket.senderID,
+ recipientID: nil,
+ timestamp: bundlePacket.timestamp,
+ payload: try #require(forged.encode()),
+ signature: bundlePacket.signature,
+ ttl: bundlePacket.ttl
+ )
+ alice._test_handlePacket(forgedPacket, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let cached = await TestHelpers.waitUntil(
+ { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!cached)
+ }
+
+ @Test func verifiedBundleEntersGossipStore() async throws {
+ let alice = makeService()
+ let bob = makeService()
+
+ let bobOut = PacketTap()
+ bob._test_onOutboundPacket = bobOut.record
+
+ let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
+ alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
+ alice._test_handlePacket(bundlePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let cached = await TestHelpers.waitUntil(
+ { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(cached)
+ // The verified bundle now participates in Alice's sync rounds.
+ #expect(alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
+ }
+
+ @Test func spoofedSenderPrekeyBundleIsRejected() async throws {
+ let alice = makeService()
+ let bob = makeService()
+
+ let bobOut = PacketTap()
+ bob._test_onOutboundPacket = bobOut.record
+
+ let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
+ alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ // A relay re-broadcasts Bob's genuine bundle under a fabricated sender
+ // ID (the DoS that would multiply cache/gossip entries and exhaust the
+ // per-owner cap). Attribution is by the bundle's own key and the outer
+ // signature is bound to Bob's sender ID, so the spoof is dropped — no
+ // cache entry, and no gossip entry under either the fake or real ID.
+ let fakeSender = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
+ let spoofed = BitchatPacket(
+ type: MessageType.prekeyBundle.rawValue,
+ senderID: fakeSender,
+ recipientID: nil,
+ timestamp: bundlePacket.timestamp + 5_000,
+ payload: bundlePacket.payload,
+ signature: bundlePacket.signature,
+ ttl: bundlePacket.ttl
+ )
+ alice._test_handlePacket(spoofed, fromPeerID: PeerID(hexData: fakeSender), preseedPeer: false)
+
+ let cached = await TestHelpers.waitUntil(
+ { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!cached)
+ #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
+ #expect(!alice._test_hasGossipPrekeyBundle(for: PeerID(hexData: fakeSender)))
+ }
+
+ @Test func replayedPrekeyBundleWithFreshTimestampIsRejected() async throws {
+ let alice = makeService()
+ let bob = makeService()
+
+ let bobOut = PacketTap()
+ bob._test_onOutboundPacket = bobOut.record
+
+ let (announce, bundlePacket) = try await captureAnnounceAndBundle(from: bob, tap: bobOut)
+ alice._test_handlePacket(announce, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ // Rewriting the outer timestamp (to defeat the freshness window)
+ // invalidates the packet signature, which covers senderID + timestamp.
+ let replay = BitchatPacket(
+ type: MessageType.prekeyBundle.rawValue,
+ senderID: bundlePacket.senderID,
+ recipientID: nil,
+ timestamp: bundlePacket.timestamp + 5_000,
+ payload: bundlePacket.payload,
+ signature: bundlePacket.signature,
+ ttl: bundlePacket.ttl
+ )
+ alice._test_handlePacket(replay, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let cached = await TestHelpers.waitUntil(
+ { alice.prekeyBundleStore.hasUsableBundle(for: bob.noiseStaticPublicKeyData()) },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!cached)
+ #expect(!alice._test_hasGossipPrekeyBundle(for: bob.myPeerID))
+ }
+}
diff --git a/bitchatTests/GCSFilterTests.swift b/bitchatTests/GCSFilterTests.swift
index 464f368e..b544f843 100644
--- a/bitchatTests/GCSFilterTests.swift
+++ b/bitchatTests/GCSFilterTests.swift
@@ -41,6 +41,24 @@ struct GCSFilterTests {
#expect(truncated.allSatisfy { full.contains($0) })
}
+ @Test func buildFilterReportsFullCoverageWhenBudgetFits() {
+ let ids = (0..<8).map { i in Data(repeating: UInt8(i), count: 16) }
+ let params = GCSFilter.buildFilter(ids: ids, maxBytes: 1024, targetFpr: 0.01)
+ #expect(params.includedCount == ids.count)
+ }
+
+ @Test func buildFilterTrimsTailWhenBudgetExceeded() {
+ // A tight byte budget can't hold every ID, so the encoder trims from
+ // the input tail and reports how many it actually covered.
+ let ids = (0..<200).map { i in
+ Data((0..<16).map { UInt8((i &* 31 &+ $0) & 0xFF) })
+ }
+ let params = GCSFilter.buildFilter(ids: ids, maxBytes: 32, targetFpr: 0.01)
+ #expect(params.includedCount > 0)
+ #expect(params.includedCount < ids.count)
+ #expect(params.data.count <= 32)
+ }
+
@Test func requestSyncPacketDecodeRejectsOversizedP() {
let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02]))
#expect(RequestSyncPacket.decode(from: valid.encode()) != nil)
diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift
index 54e65afc..de1fa209 100644
--- a/bitchatTests/GossipSyncManagerTests.swift
+++ b/bitchatTests/GossipSyncManagerTests.swift
@@ -139,6 +139,7 @@ struct GossipSyncManagerTests {
config.messageSyncIntervalSeconds = 1
config.fragmentSyncIntervalSeconds = 1
config.fileTransferSyncIntervalSeconds = 1
+ config.prekeyBundleSyncIntervalSeconds = 1
config.maintenanceIntervalSeconds = 0
let requestSyncManager = RequestSyncManager()
@@ -194,15 +195,251 @@ struct GossipSyncManagerTests {
manager._performMaintenanceSynchronously(now: Date())
+ // One request per due schedule so each type group gets the full
+ // filter capacity: publicMessages, fragment, fileTransfer, and
+ // prekeyBundle.
let sentPackets = delegate.packets
- #expect(sentPackets.count == 1)
+ #expect(sentPackets.count == 4)
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
- #expect(decoded.count == 1)
- let types = try #require(decoded.first?.types)
- #expect(types.contains(.announce))
- #expect(types.contains(.message))
- #expect(types.contains(.fragment))
- #expect(types.contains(.fileTransfer))
+ #expect(decoded.count == 4)
+ let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
+ #expect(allTypes.contains(.announce))
+ #expect(allTypes.contains(.message))
+ #expect(allTypes.contains(.fragment))
+ #expect(allTypes.contains(.fileTransfer))
+ #expect(allTypes.contains(.prekeyBundle))
+ #expect(allTypes.contains(.groupMessage))
+ // The message schedule also asks for group messages (bit 10);
+ // responders that don't know the bit just ignore it.
+ #expect(decoded.contains { $0.types == SyncTypeFlags.publicMessages.union(.groupMessage) })
+ #expect(decoded.contains { $0.types == .fragment })
+ #expect(decoded.contains { $0.types == .fileTransfer })
+ #expect(decoded.contains { $0.types == .prekeyBundle })
+ }
+
+ @Test func truncatedFilterCarriesSinceCursor() throws {
+ var config = GossipSyncManager.Config()
+ config.seenCapacity = 100
+ config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element)
+ config.messageSyncIntervalSeconds = 1
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.maintenanceIntervalSeconds = 0
+
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ let sender = try #require(Data(hexString: "1122334455667788"))
+ let baseTimestamp = UInt64(Date().timeIntervalSince1970 * 1000)
+ let totalMessages = 40
+ for i in 0..= baseTimestamp + 12)
+ #expect(since < baseTimestamp + UInt64(totalMessages))
+ }
+
+ @Test func fullCoverageFilterOmitsSinceCursor() throws {
+ var config = GossipSyncManager.Config()
+ config.seenCapacity = 100
+ config.messageSyncIntervalSeconds = 1
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.maintenanceIntervalSeconds = 0
+
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ let sender = try #require(Data(hexString: "1122334455667788"))
+ let packet = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data([0x01]),
+ signature: nil,
+ ttl: 1
+ )
+ manager.onPublicPacketSeen(packet)
+
+ manager._performMaintenanceSynchronously(now: Date())
+
+ let sent = try #require(delegate.packets.first)
+ let request = try #require(RequestSyncPacket.decode(from: sent.payload))
+ #expect(request.sinceTimestamp == nil)
+ }
+
+ @Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws {
+ var config = GossipSyncManager.Config()
+ config.seenCapacity = 5
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.prekeyBundleSyncIntervalSeconds = 0
+
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ let sender = try #require(Data(hexString: "aabbccddeeff0011"))
+ let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
+
+ // Announce older than the cursor: must still be sent (identity is
+ // needed to verify everything else).
+ let announcePacket = BitchatPacket(
+ type: MessageType.announce.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: nowMs - 50_000,
+ payload: Data(),
+ signature: nil,
+ ttl: 1
+ )
+ let oldMessage = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: nowMs - 60_000,
+ payload: Data([0x01]),
+ signature: nil,
+ ttl: 1
+ )
+ let newMessage = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: nowMs,
+ payload: Data([0x02]),
+ signature: nil,
+ ttl: 1
+ )
+
+ manager.onPublicPacketSeen(announcePacket)
+ manager.onPublicPacketSeen(oldMessage)
+ manager.onPublicPacketSeen(newMessage)
+
+ let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
+ let request = RequestSyncPacket(
+ p: 7,
+ m: 1,
+ data: Data(),
+ types: .publicMessages,
+ sinceTimestamp: nowMs - 30_000
+ )
+ manager.handleRequestSync(from: peer, request: request)
+
+ try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
+ // Barrier: flush the sync queue so a late third packet would be visible.
+ manager._performMaintenanceSynchronously(now: Date())
+ let sentPackets = delegate.packets
+ #expect(sentPackets.count == 2)
+ #expect(sentPackets.contains { $0.type == MessageType.announce.rawValue })
+ let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue }
+ #expect(sentMessages.count == 1)
+ #expect(sentMessages.first?.payload == Data([0x02]))
+ #expect(sentPackets.allSatisfy { $0.isRSR })
+ }
+
+ @Test func handleRequestSyncSkipsAnnounceAlreadyInFilter() async throws {
+ var config = GossipSyncManager.Config()
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.prekeyBundleSyncIntervalSeconds = 0
+
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ let sender = try #require(Data(hexString: "aabbccddeeff0011"))
+ let announcePacket = BitchatPacket(
+ type: MessageType.announce.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data(),
+ signature: nil,
+ ttl: 1
+ )
+ manager.onPublicPacketSeen(announcePacket)
+
+ // A filter that already contains the announce's canonical ID must
+ // suppress the response — this only holds if the responder recomputes
+ // the ID the same way the filter was built (the dual-path bug would
+ // diff a stored hex string instead).
+ let announceID = PacketIdUtil.computeId(announcePacket)
+ let params = GCSFilter.buildFilter(ids: [announceID], maxBytes: 256, targetFpr: 0.01)
+ let request = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: .announce)
+
+ let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
+ manager.handleRequestSync(from: peer, request: request)
+ // Barrier: the async handler is enqueued, so this sync flush runs after it.
+ manager._performMaintenanceSynchronously(now: Date())
+ #expect(delegate.packets.isEmpty)
+ }
+
+ @Test func handleRequestSyncIsRateLimitedPerPeer() async throws {
+ var config = GossipSyncManager.Config()
+ config.seenCapacity = 5
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.prekeyBundleSyncIntervalSeconds = 0
+ config.responseRateLimitMaxResponses = 1
+ config.responseRateLimitWindowSeconds = 60
+
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ let sender = try #require(Data(hexString: "aabbccddeeff0011"))
+ let messagePacket = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data([0x10]),
+ signature: nil,
+ ttl: 1
+ )
+ manager.onPublicPacketSeen(messagePacket)
+
+ let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
+ let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message)
+ manager.handleRequestSync(from: peer, request: request)
+ manager.handleRequestSync(from: peer, request: request)
+
+ try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
+ // Barrier: both requests have been processed once this returns.
+ manager._performMaintenanceSynchronously(now: Date())
+ #expect(delegate.packets.count == 1)
}
@Test func initialSyncCoalescesEnabledTypes() async throws {
@@ -228,6 +465,7 @@ struct GossipSyncManagerTests {
#expect(types.contains(.message))
#expect(types.contains(.fragment))
#expect(types.contains(.fileTransfer))
+ #expect(types.contains(.prekeyBundle))
}
@Test func handleRequestSyncHonorsTypeFilter() async throws {
@@ -279,10 +517,263 @@ struct GossipSyncManagerTests {
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
+
+ // MARK: - Fragment-ID filter (targeted resync)
+
+ private func makeFragmentPacket(sender: Data, fragmentID: Data, index: UInt16, timestamp: UInt64) -> BitchatPacket {
+ // Fragment payload: 8-byte stream ID + index + total + original type.
+ var payload = fragmentID
+ payload.append(contentsOf: withUnsafeBytes(of: index.bigEndian) { Data($0) })
+ payload.append(contentsOf: withUnsafeBytes(of: UInt16(4).bigEndian) { Data($0) })
+ payload.append(MessageType.fileTransfer.rawValue)
+ payload.append(Data([0xEE]))
+ return BitchatPacket(
+ type: MessageType.fragment.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: timestamp,
+ payload: payload,
+ signature: nil,
+ ttl: 1
+ )
+ }
+
+ @Test func handleRequestSyncHonorsFragmentIdFilter() async throws {
+ var config = GossipSyncManager.Config()
+ config.fragmentCapacity = 10
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.prekeyBundleSyncIntervalSeconds = 0
+
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ let sender = try #require(Data(hexString: "aabbccddeeff0011"))
+ let wantedID = try #require(Data(hexString: "0102030405060708"))
+ let otherID = try #require(Data(hexString: "1112131415161718"))
+ let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
+
+ let wanted = makeFragmentPacket(sender: sender, fragmentID: wantedID, index: 1, timestamp: nowMs - 60_000)
+ let other = makeFragmentPacket(sender: sender, fragmentID: otherID, index: 2, timestamp: nowMs)
+ manager.onPublicPacketSeen(wanted)
+ manager.onPublicPacketSeen(other)
+
+ // The since-cursor sits after both fragments; without the filter the
+ // responder would send nothing for `wanted`. The filter both bypasses
+ // the cursor and restricts the diff to exactly the named stream.
+ let request = RequestSyncPacket(
+ p: 7,
+ m: 1,
+ data: Data(),
+ types: .fragment,
+ sinceTimestamp: nowMs + 1,
+ fragmentIdFilter: RequestSyncPacket.encodeFragmentIdFilter([wantedID])
+ )
+ manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
+
+ try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
+ // Barrier: flush the sync queue so a late second packet would be visible.
+ manager._performMaintenanceSynchronously(now: Date())
+ let sentPackets = delegate.packets
+ #expect(sentPackets.count == 1)
+ let sent = try #require(sentPackets.first)
+ #expect(sent.type == MessageType.fragment.rawValue)
+ #expect(sent.payload.prefix(8) == wantedID)
+ #expect(sent.ttl == 0)
+ #expect(sent.isRSR)
+ }
+
+ @Test func requestMissingFragmentsSendsFilteredRequestToConnectedPeers() async throws {
+ var config = GossipSyncManager.Config()
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ let requestSyncManager = RequestSyncManager()
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+ let delegate = RecordingDelegate()
+ delegate.connectedPeers = [PeerID(str: "FFFFFFFFFFFFFFFF")]
+ manager.delegate = delegate
+
+ let stalledID = try #require(Data(hexString: "0102030405060708"))
+ manager.requestMissingFragments(fragmentIDs: [stalledID])
+
+ try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
+ let sent = try #require(delegate.packets.first)
+ #expect(sent.type == MessageType.requestSync.rawValue)
+ #expect(sent.ttl == 0)
+ let request = try #require(RequestSyncPacket.decode(from: sent.payload))
+ #expect(request.types == .fragment)
+ let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter))
+ #expect(ids == Set([stalledID]))
+ }
+
+ @Test func prekeyBundlesServeSyncAndSurviveStalePeerCleanup() async throws {
+ var config = GossipSyncManager.Config()
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.prekeyBundleSyncIntervalSeconds = 0
+ config.stalePeerCleanupIntervalSeconds = 0
+ config.stalePeerTimeoutSeconds = 5
+
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
+ let delegate = RecordingDelegate()
+ manager.delegate = delegate
+
+ // Bundles are keyed by their authenticated identity (the noise static
+ // key), not the packet senderID, so the payload must be a real bundle.
+ let noiseKey = Data(repeating: 0xAB, count: 32)
+ let senderPeer = PeerID(publicKey: noiseKey)
+ let sender = try #require(Data(hexString: senderPeer.id))
+ let bundle = PrekeyBundle(
+ noiseStaticPublicKey: noiseKey,
+ prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x11, count: 32))],
+ generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
+ signature: Data(count: PrekeyBundle.signatureLength)
+ )
+ let bundlePacket = BitchatPacket(
+ type: MessageType.prekeyBundle.rawValue,
+ senderID: sender,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: try #require(bundle.encode()),
+ signature: nil,
+ ttl: 1
+ )
+ manager.onPublicPacketSeen(bundlePacket)
+ manager._performMaintenanceSynchronously(now: Date())
+ #expect(manager._hasPrekeyBundle(for: senderPeer))
+
+ // Bundles outlive the owner's announce: a leave plus stale cleanup
+ // must not drop them (they exist to reach offline owners).
+ manager.removeAnnouncementForPeer(senderPeer)
+ manager._performMaintenanceSynchronously(now: Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1))
+ #expect(manager._hasPrekeyBundle(for: senderPeer))
+
+ // And a .prekeyBundle sync request is answered with the stored packet.
+ let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .prekeyBundle)
+ manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
+ try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
+ let served = try #require(delegate.packets.first)
+ #expect(served.type == MessageType.prekeyBundle.rawValue)
+ #expect(served.isRSR)
+ }
+
+ @Test func prekeyBundleGossipIsKeyedByOwnerNotSenderID() {
+ // One valid bundle re-broadcast under many fabricated sender IDs must
+ // collapse to a single entry keyed by the bundle's own identity — the
+ // spray-to-exhaust-the-cap DoS produces one entry, not N.
+ let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: RequestSyncManager())
+ let noiseKey = Data(repeating: 0xCD, count: 32)
+ let ownerPeer = PeerID(publicKey: noiseKey)
+ let bundle = PrekeyBundle(
+ noiseStaticPublicKey: noiseKey,
+ prekeys: [PrekeyBundle.Prekey(id: 0, publicKey: Data(repeating: 0x22, count: 32))],
+ generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
+ signature: Data(count: PrekeyBundle.signatureLength)
+ )
+ guard let payload = bundle.encode() else { return }
+
+ for i in 0..<5 {
+ let fakeSender = Data((0..<8).map { j in UInt8(truncatingIfNeeded: i * 31 + j) })
+ let packet = BitchatPacket(
+ type: MessageType.prekeyBundle.rawValue,
+ senderID: fakeSender,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + UInt64(i),
+ payload: payload,
+ signature: nil,
+ ttl: 1
+ )
+ manager.onPublicPacketSeen(packet)
+ manager._performMaintenanceSynchronously(now: Date())
+ // No fabricated sender ID ever creates its own entry.
+ #expect(!manager._hasPrekeyBundle(for: PeerID(hexData: fakeSender)))
+ }
+ // Exactly the owner-keyed entry exists.
+ #expect(manager._hasPrekeyBundle(for: ownerPeer))
+ }
+
+ // MARK: - Archive persistence
+
+ @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let senderID = try #require(Data(hexString: "1122334455667788"))
+ let packet = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: senderID,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data([0x01, 0x02]),
+ signature: nil,
+ ttl: 1
+ )
+
+ let first = GossipSyncManager(
+ myPeerID: myPeerID,
+ requestSyncManager: RequestSyncManager(),
+ archive: GossipMessageArchive(fileURL: fileURL)
+ )
+ first.onPublicPacketSeen(packet)
+ // Maintenance persists the dirty store to disk.
+ first._performMaintenanceSynchronously(now: Date())
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+
+ // "App restart": a fresh manager over the same archive re-serves it.
+ let second = GossipSyncManager(
+ myPeerID: myPeerID,
+ requestSyncManager: RequestSyncManager(),
+ archive: GossipMessageArchive(fileURL: fileURL)
+ )
+ let restored = await TestHelpers.waitUntil(
+ { second._messageCount(for: PeerID(hexData: senderID)) == 1 },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(restored)
+ }
+
+ @Test func archiveDropsMessagesOlderThanPublicWindow() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ var config = GossipSyncManager.Config()
+ config.publicMessageMaxAgeSeconds = 60
+
+ let senderID = try #require(Data(hexString: "1122334455667788"))
+ let stale = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: senderID,
+ recipientID: nil,
+ timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000),
+ payload: Data([0x01]),
+ signature: nil,
+ ttl: 1
+ )
+ let archive = GossipMessageArchive(fileURL: fileURL)
+ archive.save([stale.toBinaryData(padding: false)!])
+
+ let manager = GossipSyncManager(
+ myPeerID: myPeerID,
+ config: config,
+ requestSyncManager: RequestSyncManager(),
+ archive: archive
+ )
+ manager._performMaintenanceSynchronously(now: Date())
+ #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0)
+ }
+
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
var onSend: (() -> Void)?
+ var connectedPeers: [PeerID] = []
private(set) var lastPacket: BitchatPacket?
private(set) var packets: [BitchatPacket] = []
private let lock = NSLock()
@@ -304,6 +795,6 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
}
func getConnectedPeers() -> [PeerID] {
- return []
+ return connectedPeers
}
}
diff --git a/bitchatTests/MessageRateLimiterTests.swift b/bitchatTests/MessageRateLimiterTests.swift
new file mode 100644
index 00000000..c9959761
--- /dev/null
+++ b/bitchatTests/MessageRateLimiterTests.swift
@@ -0,0 +1,119 @@
+//
+// MessageRateLimiterTests.swift
+// bitchatTests
+//
+// Tests for the public-intake token buckets, including the NIP-13
+// proof-of-work relaxation of the per-sender bucket.
+//
+
+import Foundation
+import Testing
+@testable import bitchat
+
+struct MessageRateLimiterTests {
+
+ private func makeLimiter(
+ senderCapacity: Double = 2,
+ contentCapacity: Double = 100
+ ) -> MessageRateLimiter {
+ MessageRateLimiter(
+ senderCapacity: senderCapacity,
+ senderRefillPerSec: 0.0001,
+ contentCapacity: contentCapacity,
+ contentRefillPerSec: 0.0001
+ )
+ }
+
+ @Test func senderBucketBlocksAfterCapacity() {
+ var limiter = makeLimiter()
+ let now = Date()
+
+ let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
+ let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
+ let third = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
+ let otherSender = limiter.allow(senderKey: "other", contentKey: "c4", now: now)
+
+ #expect(first)
+ #expect(second)
+ #expect(!third)
+ #expect(otherSender)
+ }
+
+ @Test func validPoWBypassesExhaustedSenderBucket() {
+ var limiter = makeLimiter()
+ let now = Date()
+
+ // Exhaust the sender bucket with plain (no-PoW) messages.
+ let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
+ let second = limiter.allow(senderKey: "s", contentKey: "c2", now: now)
+ let exhausted = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
+
+ // A message carrying sufficient validated PoW still passes, and so
+ // does more-than-sufficient PoW; plain messages stay blocked.
+ let powExact = limiter.allow(
+ senderKey: "s",
+ contentKey: "c4",
+ powBits: NostrPoW.rateLimitBypassBits,
+ now: now
+ )
+ let powHigh = limiter.allow(senderKey: "s", contentKey: "c5", powBits: 20, now: now)
+ let plainAgain = limiter.allow(senderKey: "s", contentKey: "c6", now: now)
+
+ #expect(first)
+ #expect(second)
+ #expect(!exhausted)
+ #expect(powExact)
+ #expect(powHigh)
+ #expect(!plainAgain)
+ }
+
+ @Test func lowPoWDoesNotBypassSenderBucket() {
+ var limiter = makeLimiter(senderCapacity: 1)
+ let now = Date()
+
+ let first = limiter.allow(senderKey: "s", contentKey: "c1", now: now)
+ let lowPow = limiter.allow(
+ senderKey: "s",
+ contentKey: "c2",
+ powBits: NostrPoW.rateLimitBypassBits - 1,
+ now: now
+ )
+ let zeroPow = limiter.allow(senderKey: "s", contentKey: "c3", powBits: 0, now: now)
+
+ #expect(first)
+ #expect(!lowPow)
+ #expect(!zeroPow)
+ }
+
+ @Test func powDoesNotBypassContentFloodBucket() {
+ var limiter = makeLimiter(senderCapacity: 100, contentCapacity: 1)
+ let now = Date()
+
+ let first = limiter.allow(senderKey: "a", contentKey: "same", now: now)
+ // Identical content spammed with PoW is still throttled by the
+ // content bucket: PoW only relaxes the per-sender limit.
+ let powSameContent = limiter.allow(senderKey: "b", contentKey: "same", powBits: 20, now: now)
+ let powNewContent = limiter.allow(senderKey: "b", contentKey: "different", powBits: 20, now: now)
+
+ #expect(first)
+ #expect(!powSameContent)
+ #expect(powNewContent)
+ }
+
+ @Test func powBypassDoesNotDrainSenderBucket() {
+ var limiter = makeLimiter(senderCapacity: 1)
+ let now = Date()
+
+ // PoW messages don't consume sender tokens, so a subsequent plain
+ // message still has its full budget.
+ let powFirst = limiter.allow(senderKey: "s", contentKey: "c1", powBits: 20, now: now)
+ let powSecond = limiter.allow(senderKey: "s", contentKey: "c2", powBits: 20, now: now)
+ let plain = limiter.allow(senderKey: "s", contentKey: "c3", now: now)
+ let plainExhausted = limiter.allow(senderKey: "s", contentKey: "c4", now: now)
+
+ #expect(powFirst)
+ #expect(powSecond)
+ #expect(plain)
+ #expect(!plainExhausted)
+ }
+}
diff --git a/bitchatTests/Mocks/MockIdentityManager.swift b/bitchatTests/Mocks/MockIdentityManager.swift
index 633c388b..1ff1926a 100644
--- a/bitchatTests/Mocks/MockIdentityManager.swift
+++ b/bitchatTests/Mocks/MockIdentityManager.swift
@@ -107,12 +107,55 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {}
-
+
func isVerified(fingerprint: String) -> Bool {
true
}
-
+
func getVerifiedFingerprints() -> Set {
Set()
}
+
+ // MARK: Vouching (transitive verification)
+
+ private var vouchesByVouchee: [String: [VouchRecord]] = [:]
+ private var vouchBatchSentAt: [String: Date] = [:]
+
+ @discardableResult
+ func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
+ guard voucheeFingerprint != voucherFingerprint else { return false }
+ var records = vouchesByVouchee[voucheeFingerprint] ?? []
+ records.removeAll { $0.voucherFingerprint == voucherFingerprint }
+ records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
+ vouchesByVouchee[voucheeFingerprint] = records
+ return true
+ }
+
+ func validVouchers(for fingerprint: String) -> [VouchRecord] {
+ vouchesByVouchee[fingerprint] ?? []
+ }
+
+ func isVouched(fingerprint: String) -> Bool {
+ !(vouchesByVouchee[fingerprint] ?? []).isEmpty
+ }
+
+ func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
+ socialIdentities[fingerprint]?.trustLevel ?? .unknown
+ }
+
+ func lastVouchBatchSent(to fingerprint: String) -> Date? {
+ vouchBatchSentAt[fingerprint]
+ }
+
+ func markVouchBatchSent(to fingerprint: String, at date: Date) {
+ vouchBatchSentAt[fingerprint] = date
+ }
+
+ func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
+ nil
+ }
+
+ func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
+ []
+ }
}
diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift
index 0cdbe7a0..5f1243e8 100644
--- a/bitchatTests/Mocks/MockTransport.swift
+++ b/bitchatTests/Mocks/MockTransport.swift
@@ -42,6 +42,7 @@ final class MockTransport: Transport {
private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
+ private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
private(set) var startServicesCallCount = 0
private(set) var stopServicesCallCount = 0
private(set) var emergencyDisconnectCallCount = 0
@@ -189,6 +190,33 @@ final class MockTransport: Transport {
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
}
+ var courierSendResult = true
+ func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
+ sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers))
+ return courierSendResult
+ }
+
+ // MARK: - Mesh Diagnostics
+
+ private(set) var sentMeshPings: [PeerID] = []
+ var meshPingResult: MeshPingResult?
+ var meshPaths: [PeerID: [PeerID]] = [:]
+ var meshTopologySnapshot: MeshTopologySnapshot?
+
+ func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
+ sentMeshPings.append(peerID)
+ let result = meshPingResult
+ Task { @MainActor in completion(result) }
+ }
+
+ func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
+ meshPaths[peerID]
+ }
+
+ func currentMeshTopology() -> MeshTopologySnapshot? {
+ meshTopologySnapshot
+ }
+
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions
diff --git a/bitchatTests/Nostr/NostrPoWTests.swift b/bitchatTests/Nostr/NostrPoWTests.swift
new file mode 100644
index 00000000..1905e0f0
--- /dev/null
+++ b/bitchatTests/Nostr/NostrPoWTests.swift
@@ -0,0 +1,222 @@
+//
+// NostrPoWTests.swift
+// bitchatTests
+//
+// Tests for NIP-13 proof-of-work: leading-zero-bit counting, commitment
+// semantics, and nonce-tag mining for geohash (kind 20000) events.
+//
+
+import CryptoKit
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+struct NostrPoWTests {
+
+ // MARK: - Leading zero bits
+
+ @Test func leadingZeroBitsVectors() {
+ #expect(NostrPoW.leadingZeroBits(Data()) == 0)
+ #expect(NostrPoW.leadingZeroBits(Data([0x80])) == 0)
+ #expect(NostrPoW.leadingZeroBits(Data([0xFF, 0x00])) == 0)
+ #expect(NostrPoW.leadingZeroBits(Data([0x40])) == 1)
+ #expect(NostrPoW.leadingZeroBits(Data([0x01])) == 7)
+ #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0xF0])) == 16)
+ #expect(NostrPoW.leadingZeroBits(Data(repeating: 0x00, count: 32)) == 256)
+ }
+
+ @Test func leadingZeroBitsExactByteBoundaries() {
+ // Zero byte contributes exactly 8, then the next byte decides.
+ #expect(NostrPoW.leadingZeroBits(Data([0x00, 0xFF])) == 8)
+ #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x80])) == 8)
+ #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x7F])) == 9)
+ #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x01])) == 15)
+ #expect(NostrPoW.leadingZeroBits(Data([0x00, 0x00, 0x01])) == 23)
+ }
+
+ @Test func leadingZeroBitsMatchesNIP13ExampleVector() throws {
+ // Worked example from the NIP-13 spec: this event ID has 36 leading
+ // zero bits.
+ let idHex = "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d"
+ let idData = try #require(Data(hexString: idHex))
+ #expect(NostrPoW.leadingZeroBits(idData) == 36)
+ }
+
+ // MARK: - Commitment semantics
+
+ /// An ID with exactly 16 leading zero bits.
+ private let id16 = "0000f000" + String(repeating: "ab", count: 28)
+
+ @Test func committedTargetCountsNotActualDifficulty() {
+ // Claimed < actual: only the committed target is credited, so lucky
+ // extra zeroes earn nothing beyond the commitment.
+ let tags = [["g", "u4pruy"], ["nonce", "12345", "8"]]
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 8)
+ }
+
+ @Test func unmetCommitmentScoresZero() {
+ // Actual < claimed: the commitment is not met, so the claim is void.
+ let tags = [["nonce", "12345", "24"]]
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 0)
+ }
+
+ @Test func exactCommitmentIsCredited() {
+ let tags = [["nonce", "12345", "16"]]
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: tags) == 16)
+ }
+
+ @Test func missingOrMalformedNonceTagScoresZero() {
+ // No nonce tag at all: leading zeroes without a commitment earn no
+ // credit (old clients simply keep the strict rate limits).
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["g", "u4pruy"]]) == 0)
+ // Nonce tag without a committed target.
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "12345"]]) == 0)
+ // Non-numeric or nonsensical targets.
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "high"]]) == 0)
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "0"]]) == 0)
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "-4"]]) == 0)
+ #expect(NostrPoW.validatedDifficulty(idHex: id16, tags: [["nonce", "1", "400"]]) == 0)
+ // Malformed event ID.
+ #expect(NostrPoW.validatedDifficulty(idHex: "not-hex", tags: [["nonce", "1", "8"]]) == 0)
+ }
+
+ // MARK: - Mining
+
+ @Test func minedNonceTagMeetsCommittedDifficulty() async throws {
+ let pubkey = String(repeating: "a", count: 64)
+ let createdAt = 1_700_000_000
+ let baseTags = [["g", "u4pruydq"], ["n", "tester"]]
+ let content = "hello pow"
+
+ let nonceTag = try #require(await NostrPoW.mineNonceTag(
+ pubkey: pubkey,
+ createdAt: createdAt,
+ kind: 20000,
+ tags: baseTags,
+ content: content,
+ targetBits: 8
+ ))
+
+ #expect(nonceTag.count == 3)
+ #expect(nonceTag.first == "nonce")
+ #expect(nonceTag[2] == "8")
+
+ // Recompute the canonical NIP-01 event ID with the mined tag appended
+ // and verify the committed difficulty is genuinely met.
+ let idData = try Self.eventIDHash(
+ pubkey: pubkey,
+ createdAt: createdAt,
+ kind: 20000,
+ tags: baseTags + [nonceTag],
+ content: content
+ )
+ #expect(NostrPoW.leadingZeroBits(idData) >= 8)
+ let idHex = idData.map { String(format: "%02x", $0) }.joined()
+ #expect(NostrPoW.validatedDifficulty(idHex: idHex, tags: baseTags + [nonceTag]) == 8)
+ }
+
+ @Test func miningSurvivesContentThatNeedsEscaping() async throws {
+ // The in-place template mutation must stay correct when the content
+ // gets JSON-escaped — including content that contains hex runs that
+ // look exactly like the internal nonce placeholder.
+ let pubkey = String(repeating: "b", count: 64)
+ let createdAt = 1_700_000_123
+ let content = "she said \"hi\"\n0000000000000000 / ffffffffffffffff 😀\\"
+ let baseTags = [["g", "9q8yy"]]
+
+ let nonceTag = try #require(await NostrPoW.mineNonceTag(
+ pubkey: pubkey,
+ createdAt: createdAt,
+ kind: 20000,
+ tags: baseTags,
+ content: content,
+ targetBits: 4
+ ))
+
+ let idData = try Self.eventIDHash(
+ pubkey: pubkey,
+ createdAt: createdAt,
+ kind: 20000,
+ tags: baseTags + [nonceTag],
+ content: content
+ )
+ #expect(NostrPoW.leadingZeroBits(idData) >= 4)
+ }
+
+ @Test func minedGeohashEventValidatesEndToEnd() async throws {
+ let identity = try NostrIdentity.generate()
+ let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
+ content: "hello from a mined event",
+ geohash: "u4pruydq",
+ senderIdentity: identity,
+ nickname: "miner",
+ teleported: false
+ )
+
+ // The signed event's own ID (recomputed by sign()) carries the work.
+ #expect(event.isValidSignature())
+ let idData = try #require(Data(hexString: event.id))
+ #expect(NostrPoW.leadingZeroBits(idData) >= NostrPoW.targetBits)
+ #expect(NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) == NostrPoW.targetBits)
+
+ // Mining must not disturb the regular geohash tags.
+ #expect(event.tags.contains(["g", "u4pruydq"]))
+ #expect(event.tags.contains(["n", "miner"]))
+ #expect(event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue)
+ }
+
+ @Test func cancelledMiningStillProducesHonestCommitment() async throws {
+ // Cancelling the surrounding task expedites mining: it steps the
+ // committed target down and still returns a tag whose commitment the
+ // hash actually meets — the message is never dropped or dishonest.
+ let pubkey = String(repeating: "c", count: 64)
+ let createdAt = 1_700_000_456
+ let baseTags = [["g", "gbsuv"]]
+ let content = "expedited"
+
+ let miningTask = Task {
+ await NostrPoW.mineNonceTag(
+ pubkey: pubkey,
+ createdAt: createdAt,
+ kind: 20000,
+ tags: baseTags,
+ content: content,
+ targetBits: 240 // unreachable: forces the cap/cancel path
+ )
+ }
+ miningTask.cancel()
+
+ let nonceTag = try #require(await miningTask.value)
+ let committed = try #require(Int(nonceTag[2]))
+ #expect(committed >= 0)
+ #expect(committed < 240)
+
+ if committed > 0 {
+ let idData = try Self.eventIDHash(
+ pubkey: pubkey,
+ createdAt: createdAt,
+ kind: 20000,
+ tags: baseTags + [nonceTag],
+ content: content
+ )
+ #expect(NostrPoW.leadingZeroBits(idData) >= committed)
+ }
+ }
+
+ // MARK: - Helpers
+
+ /// Canonical NIP-01 event ID hash, computed independently of the
+ /// production code path.
+ private static func eventIDHash(
+ pubkey: String,
+ createdAt: Int,
+ kind: Int,
+ tags: [[String]],
+ content: String
+ ) throws -> Data {
+ let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
+ let json = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
+ return Data(SHA256.hash(data: json))
+ }
+}
diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift
index 39ed95bf..d496529c 100644
--- a/bitchatTests/Performance/PerformanceBaselineTests.swift
+++ b/bitchatTests/Performance/PerformanceBaselineTests.swift
@@ -611,6 +611,7 @@ private final class PerfNostrContext: ChatNostrContext {
private(set) var handledPublicMessageCount = 0
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 }
+ func handlePublicMessage(_ message: BitchatMessage, powBits: Int) { handledPublicMessageCount += 1 }
func checkForMentions(_ message: BitchatMessage) {}
func sendHapticFeedback(for message: BitchatMessage) {}
func parseMentions(from content: String) -> [String] {
diff --git a/bitchatTests/Prekeys/NoisePrekeyTests.swift b/bitchatTests/Prekeys/NoisePrekeyTests.swift
new file mode 100644
index 00000000..44a39d11
--- /dev/null
+++ b/bitchatTests/Prekeys/NoisePrekeyTests.swift
@@ -0,0 +1,283 @@
+//
+// NoisePrekeyTests.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import CryptoKit
+import BitFoundation
+@testable import bitchat
+
+/// Forward-secret one-way Noise X envelopes sealed to one-time prekeys
+/// instead of the recipient's identity static key.
+struct NoisePrekeyTests {
+
+ @Test func sealAndOpenRoundTrip() throws {
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ let prekey = try #require(bundle.prekeys.first)
+
+ let payload = Data("meet at the north gate".utf8)
+ let sealed = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
+
+ let opened = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
+ #expect(opened.payload == payload)
+ // The X pattern authenticates the sender: Bob learns Alice's real static key.
+ #expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
+ }
+
+ @Test func wrongPrekeyIDCannotOpen() throws {
+ // The prologue binds the ciphertext to a specific prekey ID; opening
+ // with a different (existing) prekey must fail.
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ #expect(bundle.prekeys.count >= 2)
+
+ let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: bundle.prekeys[0])
+ #expect(throws: (any Error).self) {
+ _ = try bob.openPrekeyPayload(sealed, prekeyID: bundle.prekeys[1].id)
+ }
+ }
+
+ @Test func unknownPrekeyIDThrows() throws {
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ let prekey = try #require(bundle.prekeys.first)
+
+ let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
+ #expect(throws: NoiseEncryptionError.unknownPrekey) {
+ _ = try bob.openPrekeyPayload(sealed, prekeyID: 0xDEAD_BEEF)
+ }
+ }
+
+ @Test func wrongRecipientCannotOpen() throws {
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let carol = NoiseEncryptionService(keychain: MockKeychain())
+ let bobBundle = try #require(bob.currentPrekeyBundle())
+ // Ensure Carol holds a prekey under the same ID as Bob's.
+ _ = try #require(carol.currentPrekeyBundle())
+ let prekey = try #require(bobBundle.prekeys.first)
+
+ let sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
+ #expect(throws: (any Error).self) {
+ _ = try carol.openPrekeyPayload(sealed, prekeyID: prekey.id)
+ }
+ }
+
+ @Test func tamperedCiphertextFailsToOpen() throws {
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ let prekey = try #require(bundle.prekeys.first)
+
+ var sealed = try alice.sealPrekeyPayload(Data("secret".utf8), recipientPrekey: prekey)
+ sealed[sealed.count - 1] ^= 0x01
+ #expect(throws: (any Error).self) {
+ _ = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
+ }
+ }
+
+ @Test func consumedPrekeyStillOpensRedeliveredCiphertext() throws {
+ // Spray-and-wait can deliver the same ciphertext via several couriers
+ // days apart; the consumed private survives a grace window for that.
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ let prekey = try #require(bundle.prekeys.first)
+
+ let sealed = try alice.sealPrekeyPayload(Data("hello".utf8), recipientPrekey: prekey)
+ let first = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
+ let second = try bob.openPrekeyPayload(sealed, prekeyID: prekey.id)
+ #expect(first.payload == second.payload)
+ }
+
+ @Test func prekeyAndStaticSealsAreNotInterchangeable() throws {
+ // Domain-separated prologues: a static-sealed envelope must not open
+ // via the prekey path and vice versa, even with matching key material.
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ let prekey = try #require(bundle.prekeys.first)
+
+ let staticSealed = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
+ #expect(throws: (any Error).self) {
+ _ = try bob.openPrekeyPayload(staticSealed, prekeyID: prekey.id)
+ }
+
+ let prekeySealed = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: prekey)
+ #expect(throws: (any Error).self) {
+ _ = try bob.openCourierPayload(prekeySealed)
+ }
+ }
+
+ @Test func sealRejectsInvalidPrekeyPublicKey() {
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ #expect(throws: (any Error).self) {
+ _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 0, count: 32)))
+ }
+ #expect(throws: (any Error).self) {
+ _ = try alice.sealPrekeyPayload(Data("x".utf8), recipientPrekey: PrekeyBundle.Prekey(id: 1, publicKey: Data(repeating: 1, count: 8)))
+ }
+ }
+
+ @Test func sealsAreNotLinkableAcrossSends() throws {
+ // Fresh ephemeral per seal even to the same prekey.
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bob = NoiseEncryptionService(keychain: MockKeychain())
+ let bundle = try #require(bob.currentPrekeyBundle())
+ let prekey = try #require(bundle.prekeys.first)
+ let payload = Data("same message".utf8)
+
+ let a = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
+ let b = try alice.sealPrekeyPayload(payload, recipientPrekey: prekey)
+ #expect(a != b)
+ #expect(a.prefix(32) != b.prefix(32))
+ }
+}
+
+/// Local one-time prekey lifecycle: batch generation, consumption, the 48h
+/// redelivery grace window, replenishment, and the panic wipe.
+struct LocalPrekeyStoreTests {
+
+ private final class Clock {
+ var now: Date
+ init(_ now: Date = Date()) { self.now = now }
+ }
+
+ private func makeStore(clock: Clock, keychain: MockKeychain = MockKeychain()) -> LocalPrekeyStore {
+ LocalPrekeyStore(keychain: keychain, now: { clock.now })
+ }
+
+ private func bundle(noiseKey: Data, prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) -> PrekeyBundle {
+ PrekeyBundle(
+ noiseStaticPublicKey: noiseKey,
+ prekeys: prekeys,
+ generatedAt: generatedAt,
+ signature: Data(count: PrekeyBundle.signatureLength)
+ )
+ }
+
+ @Test func mintsFullBatchOnFirstUse() {
+ let store = makeStore(clock: Clock())
+ let (prekeys, generatedAt) = store.currentBundlePrekeys()
+ #expect(prekeys.count == LocalPrekeyStore.Policy.batchSize)
+ #expect(generatedAt > 0)
+ #expect(Set(prekeys.map(\.id)).count == prekeys.count)
+ }
+
+ @Test func consumptionBelowThresholdTriggersReplenishAndBumpsGeneration() {
+ let clock = Clock()
+ let store = makeStore(clock: clock)
+ let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
+
+ // Consuming down to the threshold does not regenerate...
+ let keepUnconsumed = LocalPrekeyStore.Policy.replenishThreshold
+ for prekey in initial.dropLast(keepUnconsumed) {
+ store.markConsumed(prekey.id)
+ }
+ #expect(!store.replenishIfNeeded())
+ #expect(store.unconsumedCount == keepUnconsumed)
+
+ // ...one more consumption does, topping back up to a full batch with
+ // a newer generation stamp.
+ clock.now = clock.now.addingTimeInterval(60)
+ store.markConsumed(initial[initial.count - keepUnconsumed].id)
+ #expect(store.replenishIfNeeded())
+ let (replenished, secondGeneratedAt) = store.currentBundlePrekeys()
+ #expect(replenished.count == LocalPrekeyStore.Policy.batchSize)
+ #expect(secondGeneratedAt > firstGeneratedAt)
+ // Surviving unconsumed prekeys stay in the fresh bundle.
+ let survivorIDs = Set(initial.suffix(keepUnconsumed - 1).map(\.id))
+ #expect(survivorIDs.isSubset(of: Set(replenished.map(\.id))))
+ }
+
+ @Test func consumingAPrekeyRepublishesANewerBundlePeersAccept() {
+ // Codex P1: consuming a prekey (even above the replenish threshold)
+ // must republish a strictly newer bundle so a peer that cached the old
+ // one replaces it and stops assigning the consumed ID before its 48h
+ // grace lapses.
+ let clock = Clock()
+ let store = makeStore(clock: clock)
+ let noiseKey = Data(repeating: 0xC0, count: 32)
+
+ // Owner publishes; a peer caches it and would assign the first prekey.
+ let (initial, firstGeneratedAt) = store.currentBundlePrekeys()
+ let peerCache = PrekeyBundleStore(persistsToDisk: false)
+ #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: initial, generatedAt: firstGeneratedAt)))
+ let consumedID = initial[0].id
+ #expect(peerCache.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == consumedID)
+
+ // The owner opens mail sealed to that prekey: it's retired and the
+ // republished bundle is strictly newer and no longer offers the ID.
+ #expect(store.markConsumed(consumedID))
+ let (afterConsume, secondGeneratedAt) = store.currentBundlePrekeys()
+ #expect(secondGeneratedAt > firstGeneratedAt)
+ #expect(!afterConsume.contains { $0.id == consumedID })
+
+ // The peer accepts the replacement (a same-generatedAt copy would be
+ // rejected) and stops assigning the consumed ID for new mail.
+ #expect(peerCache.ingest(bundle(noiseKey: noiseKey, prekeys: afterConsume, generatedAt: secondGeneratedAt)))
+ #expect(peerCache.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id != consumedID)
+
+ // 48h grace: the owner can still open a redelivery of the in-flight
+ // ciphertext sealed to the consumed ID until the window lapses.
+ clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
+ #expect(store.privateKey(for: consumedID) != nil)
+ clock.now = clock.now.addingTimeInterval(120)
+ #expect(store.privateKey(for: consumedID) == nil)
+ }
+
+ @Test func consumedPrivateSurvivesGraceWindowThenDies() {
+ let clock = Clock()
+ let store = makeStore(clock: clock)
+ let (prekeys, _) = store.currentBundlePrekeys()
+ let id = prekeys[0].id
+
+ store.markConsumed(id)
+ // Within the grace window: still retrievable for redeliveries.
+ clock.now = clock.now.addingTimeInterval(LocalPrekeyStore.Policy.consumedGraceSeconds - 60)
+ #expect(store.privateKey(for: id) != nil)
+
+ // Past the grace window: gone (even before replenish prunes it).
+ clock.now = clock.now.addingTimeInterval(120)
+ #expect(store.privateKey(for: id) == nil)
+ store.replenishIfNeeded()
+ #expect(store.privateKey(for: id) == nil)
+ }
+
+ @Test func persistsAcrossInstances() {
+ let keychain = MockKeychain()
+ let clock = Clock()
+ let first = LocalPrekeyStore(keychain: keychain, now: { clock.now })
+ let (prekeys, generatedAt) = first.currentBundlePrekeys()
+ first.markConsumed(prekeys[0].id)
+
+ let second = LocalPrekeyStore(keychain: keychain, now: { clock.now })
+ let (reloaded, reloadedGeneratedAt) = second.currentBundlePrekeys()
+ // Consuming a prekey shrinks the published bundle, so its generation
+ // stamp advances strictly (even without the clock moving) — peers must
+ // see a newer bundle to replace the one that still offered the
+ // consumed ID.
+ #expect(reloadedGeneratedAt > generatedAt)
+ #expect(Set(reloaded.map(\.id)) == Set(prekeys.dropFirst().map(\.id)))
+ // The consumed key is still openable within grace after a relaunch.
+ #expect(second.privateKey(for: prekeys[0].id) != nil)
+ }
+
+ @Test func wipeRemovesEverything() {
+ let keychain = MockKeychain()
+ let store = LocalPrekeyStore(keychain: keychain)
+ let (prekeys, _) = store.currentBundlePrekeys()
+ store.wipe()
+ #expect(store.privateKey(for: prekeys[0].id) == nil)
+ #expect(keychain.getIdentityKey(forKey: "prekeysV1") == nil)
+ }
+}
diff --git a/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift
new file mode 100644
index 00000000..319757f0
--- /dev/null
+++ b/bitchatTests/Prekeys/PrekeyBundleStoreTests.swift
@@ -0,0 +1,197 @@
+//
+// PrekeyBundleStoreTests.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import CryptoKit
+import BitFoundation
+@testable import bitchat
+
+/// Sender-side cache of peers' verified prekey bundles: latest-wins ingest,
+/// per-message prekey assignment (never reused across messages), expiry, and
+/// the peer cap.
+struct PrekeyBundleStoreTests {
+
+ private func makeBundle(
+ noiseKey: Data = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation,
+ ids: [UInt32] = [0, 1, 2],
+ generatedAt: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000)
+ ) -> PrekeyBundle {
+ PrekeyBundle(
+ noiseStaticPublicKey: noiseKey,
+ prekeys: ids.map { PrekeyBundle.Prekey(id: $0, publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation) },
+ generatedAt: generatedAt,
+ signature: Data(count: PrekeyBundle.signatureLength)
+ )
+ }
+
+ @Test func ingestKeepsLatestByGeneratedAt() {
+ let store = PrekeyBundleStore(persistsToDisk: false)
+ let noiseKey = Data(repeating: 0xB0, count: 32)
+ let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
+
+ let old = makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)
+ let new = makeBundle(noiseKey: noiseKey, ids: [2, 3], generatedAt: nowMs)
+
+ #expect(store.ingest(new))
+ // Older (and equal) bundles never displace a newer one.
+ #expect(!store.ingest(old))
+ #expect(!store.ingest(new))
+
+ let assigned = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
+ #expect(assigned?.id == 2)
+ }
+
+ @Test func assignmentsConsumeDistinctPrekeysPerMessage() {
+ let store = PrekeyBundleStore(persistsToDisk: false)
+ let noiseKey = Data(repeating: 0xB1, count: 32)
+ #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [10, 11])))
+
+ let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
+ let second = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
+ #expect(first?.id == 10)
+ #expect(second?.id == 11)
+ // Exhausted: fall back to static sealing.
+ #expect(store.assignPrekey(messageID: "m3", recipientNoiseKey: noiseKey) == nil)
+ #expect(!store.hasUsableBundle(for: noiseKey))
+ }
+
+ @Test func redepositOfSameMessageReusesItsPrekey() {
+ let store = PrekeyBundleStore(persistsToDisk: false)
+ let noiseKey = Data(repeating: 0xB2, count: 32)
+ #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [5, 6, 7])))
+
+ let first = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
+ let retry = store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)
+ #expect(first?.id == retry?.id)
+ #expect(first?.publicKey == retry?.publicKey)
+ // Only one prekey was burned.
+ let next = store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)
+ #expect(next?.id == 6)
+ }
+
+ @Test func topUpBundleKeepsConsumptionStateForSurvivingIDs() {
+ let store = PrekeyBundleStore(persistsToDisk: false)
+ let noiseKey = Data(repeating: 0xB3, count: 32)
+ let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
+ #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [0, 1], generatedAt: nowMs - 1000)))
+ #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 0)
+
+ // The owner topped up: ID 1 survives (still unconsumed on their side),
+ // ID 0 rotated out, new IDs appear.
+ #expect(store.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 8, 9], generatedAt: nowMs)))
+ // m1's assignment referenced a rotated-out ID; a re-deposit picks a
+ // fresh one rather than sealing to a dead key.
+ #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
+ #expect(store.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 8)
+ }
+
+ @Test func expiredBundleIsNeverUsed() {
+ var current = Date()
+ let store = PrekeyBundleStore(persistsToDisk: false, now: { current })
+ let noiseKey = Data(repeating: 0xB4, count: 32)
+ #expect(store.ingest(makeBundle(noiseKey: noiseKey, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
+ #expect(store.hasUsableBundle(for: noiseKey))
+
+ current = current.addingTimeInterval(PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds + 60)
+ #expect(!store.hasUsableBundle(for: noiseKey))
+ #expect(store.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey) == nil)
+ }
+
+ @Test func peerCapEvictsLeastRecentlyUpdated() {
+ var current = Date()
+ let store = PrekeyBundleStore(persistsToDisk: false, maxPeers: 2, now: { current })
+ let keys = (0..<3).map { Data(repeating: UInt8(0xC0 + $0), count: 32) }
+
+ for key in keys {
+ #expect(store.ingest(makeBundle(noiseKey: key, generatedAt: UInt64(current.timeIntervalSince1970 * 1000))))
+ current = current.addingTimeInterval(1)
+ }
+ // Oldest entry evicted; the two most recent survive.
+ #expect(!store.hasUsableBundle(for: keys[0]))
+ #expect(store.hasUsableBundle(for: keys[1]))
+ #expect(store.hasUsableBundle(for: keys[2]))
+ }
+
+ @Test func persistsAcrossInstancesAndWipes() throws {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("prekey-bundle-store-tests-\(UUID().uuidString)", isDirectory: true)
+ let fileURL = dir.appendingPathComponent("bundles.json")
+ defer { try? FileManager.default.removeItem(at: dir) }
+
+ let noiseKey = Data(repeating: 0xB5, count: 32)
+ let first = PrekeyBundleStore(fileURL: fileURL)
+ #expect(first.ingest(makeBundle(noiseKey: noiseKey, ids: [1, 2])))
+ #expect(first.assignPrekey(messageID: "m1", recipientNoiseKey: noiseKey)?.id == 1)
+
+ // Consumption state survives a relaunch, so a restart can't reuse a prekey.
+ let second = PrekeyBundleStore(fileURL: fileURL)
+ #expect(second.assignPrekey(messageID: "m2", recipientNoiseKey: noiseKey)?.id == 2)
+
+ second.wipe()
+ #expect(!FileManager.default.fileExists(atPath: fileURL.path))
+ let third = PrekeyBundleStore(fileURL: fileURL)
+ #expect(!third.hasUsableBundle(for: noiseKey))
+ }
+}
+
+/// Envelope v2 wire compatibility: the prekey ID rides an optional TLV that
+/// v1 decoders skip as unknown.
+struct CourierEnvelopeV2Tests {
+
+ @Test func prekeyIDRoundTrips() throws {
+ let envelope = CourierEnvelope(
+ recipientTag: Data(repeating: 0x11, count: CourierEnvelope.tagLength),
+ expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
+ ciphertext: Data("ciphertext".utf8),
+ copies: 4,
+ prekeyID: 0xAABB_CCDD
+ )
+ let encoded = try #require(envelope.encode())
+ let decoded = try #require(CourierEnvelope.decode(encoded))
+ #expect(decoded == envelope)
+ #expect(decoded.prekeyID == 0xAABB_CCDD)
+ }
+
+ @Test func v1EnvelopeDecodesWithNilPrekeyID() throws {
+ let envelope = CourierEnvelope(
+ recipientTag: Data(repeating: 0x22, count: CourierEnvelope.tagLength),
+ expiry: UInt64(Date().timeIntervalSince1970 * 1000) + 60_000,
+ ciphertext: Data("legacy".utf8)
+ )
+ let encoded = try #require(envelope.encode())
+ let decoded = try #require(CourierEnvelope.decode(encoded))
+ #expect(decoded.prekeyID == nil)
+ }
+
+ @Test func v1EncodingIsByteIdenticalWithoutPrekeyID() throws {
+ // Static-sealed envelopes must stay on the pre-prekey wire format.
+ let tag = Data(repeating: 0x33, count: CourierEnvelope.tagLength)
+ let expiry: UInt64 = 1_800_000_000_000
+ let ciphertext = Data("same".utf8)
+ let v1 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext).encode())
+ let v1Explicit = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: nil).encode())
+ #expect(v1 == v1Explicit)
+ // And a v2 envelope is the v1 bytes plus one trailing TLV a v1
+ // decoder skips as unknown.
+ let v2 = try #require(CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext, prekeyID: 7).encode())
+ #expect(v2.prefix(v1.count) == v1)
+ #expect(v2.count == v1.count + 3 + 4)
+ }
+
+ @Test func withCopiesPreservesPrekeyID() {
+ let envelope = CourierEnvelope(
+ recipientTag: Data(repeating: 0x44, count: CourierEnvelope.tagLength),
+ expiry: 1,
+ ciphertext: Data([0x01]),
+ copies: 4,
+ prekeyID: 9
+ )
+ #expect(envelope.withCopies(2).prekeyID == 9)
+ }
+}
diff --git a/bitchatTests/Prekeys/PrekeyBundleTests.swift b/bitchatTests/Prekeys/PrekeyBundleTests.swift
new file mode 100644
index 00000000..8d9b212b
--- /dev/null
+++ b/bitchatTests/Prekeys/PrekeyBundleTests.swift
@@ -0,0 +1,133 @@
+//
+// PrekeyBundleTests.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import CryptoKit
+import BitFoundation
+@testable import bitchat
+
+/// Wire format and signature binding for gossiped one-time prekey bundles.
+struct PrekeyBundleTests {
+
+ private func makePrekeys(_ count: Int) -> [PrekeyBundle.Prekey] {
+ (0..
+//
+
+import CryptoKit
+import Foundation
+import Testing
+@testable import bitchat
+
+struct BoardPacketsTests {
+
+ private let authorKey = Curve25519.Signing.PrivateKey()
+
+ private func makeSignedPost(
+ geohash: String = "9q8yy",
+ content: String = "water point at the north gate",
+ nickname: String = "ranger",
+ createdAt: UInt64 = 1_700_000_000_000,
+ lifetimeMs: UInt64 = 24 * 60 * 60 * 1000,
+ flags: UInt8 = 0,
+ signWith key: Curve25519.Signing.PrivateKey? = nil,
+ claimKey: Data? = nil
+ ) throws -> BoardPostPacket {
+ let signer = key ?? authorKey
+ let publicKey = claimKey ?? signer.publicKey.rawRepresentation
+ let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
+ let expiresAt = createdAt + lifetimeMs
+ let signingBytes = BoardPostPacket.signingBytes(
+ postID: postID,
+ geohash: geohash,
+ content: content,
+ authorSigningKey: publicKey,
+ authorNickname: nickname,
+ createdAt: createdAt,
+ expiresAt: expiresAt,
+ flags: flags
+ )
+ let signature = try signer.signature(for: signingBytes)
+ return BoardPostPacket(
+ postID: postID,
+ geohash: geohash,
+ content: content,
+ authorSigningKey: publicKey,
+ authorNickname: nickname,
+ createdAt: createdAt,
+ expiresAt: expiresAt,
+ flags: flags,
+ signature: signature
+ )
+ }
+
+ private func makeSignedTombstone(
+ postID: Data,
+ deletedAt: UInt64 = 1_700_000_100_000,
+ signWith key: Curve25519.Signing.PrivateKey? = nil,
+ claimKey: Data? = nil
+ ) throws -> BoardTombstonePacket {
+ let signer = key ?? authorKey
+ let publicKey = claimKey ?? signer.publicKey.rawRepresentation
+ let signature = try signer.signature(for: BoardTombstonePacket.signingBytes(postID: postID, deletedAt: deletedAt))
+ return BoardTombstonePacket(
+ postID: postID,
+ authorSigningKey: publicKey,
+ deletedAt: deletedAt,
+ signature: signature
+ )
+ }
+
+ // MARK: - Round trips
+
+ @Test func postRoundTrip() throws {
+ let post = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
+ let encoded = BoardWire.post(post).encode()
+ let decoded = try #require(BoardWire.decode(from: encoded))
+ #expect(decoded == .post(post))
+ #expect(decoded.verifySignature())
+ guard case .post(let roundTripped) = decoded else {
+ Issue.record("expected a post")
+ return
+ }
+ #expect(roundTripped.isUrgent)
+ #expect(roundTripped.geohash == "9q8yy")
+ }
+
+ @Test func meshLocalPostRoundTrip() throws {
+ let post = try makeSignedPost(geohash: "")
+ let decoded = try #require(BoardWire.decode(from: BoardWire.post(post).encode()))
+ #expect(decoded == .post(post))
+ #expect(decoded.verifySignature())
+ }
+
+ @Test func tombstoneRoundTrip() throws {
+ let post = try makeSignedPost()
+ let tombstone = try makeSignedTombstone(postID: post.postID)
+ let encoded = BoardWire.tombstone(tombstone).encode()
+ let decoded = try #require(BoardWire.decode(from: encoded))
+ #expect(decoded == .tombstone(tombstone))
+ #expect(decoded.verifySignature())
+ }
+
+ // MARK: - Signature verification
+
+ @Test func forgedPostSignatureFailsVerification() throws {
+ // Signed by an attacker's key but claiming the victim's key as author.
+ let attacker = Curve25519.Signing.PrivateKey()
+ let victim = Curve25519.Signing.PrivateKey()
+ let forged = try makeSignedPost(signWith: attacker, claimKey: victim.publicKey.rawRepresentation)
+ let decoded = try #require(BoardWire.decode(from: BoardWire.post(forged).encode()))
+ #expect(!decoded.verifySignature())
+ }
+
+ @Test func tamperedContentFailsVerification() throws {
+ let post = try makeSignedPost(content: "meet at noon")
+ let tampered = BoardPostPacket(
+ postID: post.postID,
+ geohash: post.geohash,
+ content: "meet at midnight",
+ authorSigningKey: post.authorSigningKey,
+ authorNickname: post.authorNickname,
+ createdAt: post.createdAt,
+ expiresAt: post.expiresAt,
+ flags: post.flags,
+ signature: post.signature
+ )
+ let decoded = try #require(BoardWire.decode(from: BoardWire.post(tampered).encode()))
+ #expect(!decoded.verifySignature())
+ }
+
+ @Test func forgedTombstoneSignatureFailsVerification() throws {
+ let post = try makeSignedPost()
+ let attacker = Curve25519.Signing.PrivateKey()
+ let forged = try makeSignedTombstone(
+ postID: post.postID,
+ signWith: attacker,
+ claimKey: post.authorSigningKey
+ )
+ let decoded = try #require(BoardWire.decode(from: BoardWire.tombstone(forged).encode()))
+ #expect(!decoded.verifySignature())
+ }
+
+ // MARK: - Decode validation
+
+ @Test func rejectsExpiryBeyondSevenDays() throws {
+ let tooLong = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs + 1)
+ #expect(BoardWire.decode(from: BoardWire.post(tooLong).encode()) == nil)
+
+ let exactlySevenDays = try makeSignedPost(lifetimeMs: BoardWireConstants.maxLifetimeMs)
+ #expect(BoardWire.decode(from: BoardWire.post(exactlySevenDays).encode()) != nil)
+ }
+
+ @Test func rejectsExpiryBeforeCreation() throws {
+ let post = try makeSignedPost()
+ let inverted = BoardPostPacket(
+ postID: post.postID,
+ geohash: post.geohash,
+ content: post.content,
+ authorSigningKey: post.authorSigningKey,
+ authorNickname: post.authorNickname,
+ createdAt: post.expiresAt,
+ expiresAt: post.createdAt,
+ flags: post.flags,
+ signature: post.signature
+ )
+ #expect(BoardWire.decode(from: BoardWire.post(inverted).encode()) == nil)
+ }
+
+ @Test func rejectsOversizedContent() throws {
+ let oversized = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes + 1))
+ #expect(BoardWire.decode(from: BoardWire.post(oversized).encode()) == nil)
+
+ let maxed = try makeSignedPost(content: String(repeating: "x", count: BoardWireConstants.contentMaxBytes))
+ #expect(BoardWire.decode(from: BoardWire.post(maxed).encode()) != nil)
+ }
+
+ @Test func rejectsInvalidGeohashCharacters() throws {
+ let invalid = try makeSignedPost(geohash: "9q8yA") // "A" is outside base32
+ #expect(BoardWire.decode(from: BoardWire.post(invalid).encode()) == nil)
+ }
+
+ @Test func toleratesUnknownTLVs() throws {
+ let post = try makeSignedPost()
+ var encoded = BoardWire.post(post).encode()
+ // Append an unknown TLV; decoders must skip it.
+ encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
+ let decoded = try #require(BoardWire.decode(from: encoded))
+ #expect(decoded == .post(post))
+ #expect(decoded.verifySignature())
+ }
+
+ @Test func rejectsTruncatedPayload() throws {
+ let post = try makeSignedPost()
+ let encoded = BoardWire.post(post).encode()
+ #expect(BoardWire.decode(from: encoded.prefix(encoded.count - 1)) == nil)
+ }
+
+ // MARK: - Urgent flag peek
+
+ @Test func urgentFlagPeekMatchesFullDecode() throws {
+ let urgent = try makeSignedPost(flags: BoardPostPacket.urgentFlag)
+ let calm = try makeSignedPost()
+ let tombstone = try makeSignedTombstone(postID: calm.postID)
+ #expect(BoardWire.urgentFlag(in: BoardWire.post(urgent).encode()))
+ #expect(!BoardWire.urgentFlag(in: BoardWire.post(calm).encode()))
+ #expect(!BoardWire.urgentFlag(in: BoardWire.tombstone(tombstone).encode()))
+ #expect(!BoardWire.urgentFlag(in: Data()))
+ }
+}
diff --git a/bitchatTests/Protocols/NostrCarrierPacketTests.swift b/bitchatTests/Protocols/NostrCarrierPacketTests.swift
new file mode 100644
index 00000000..50ffb287
--- /dev/null
+++ b/bitchatTests/Protocols/NostrCarrierPacketTests.swift
@@ -0,0 +1,96 @@
+//
+// NostrCarrierPacketTests.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+import Testing
+@testable import bitchat
+
+@Suite("Nostr carrier packet TLV")
+struct NostrCarrierPacketTests {
+ private func makeEvent(geohash: String = "u4pruy", content: String = "hello mesh") throws -> NostrEvent {
+ let identity = try NostrIdentity.generate()
+ return try NostrProtocol.createEphemeralGeohashEvent(
+ content: content,
+ geohash: geohash,
+ senderIdentity: identity,
+ nickname: "tester"
+ )
+ }
+
+ @Test("round-trips both directions with the signed event intact")
+ func roundTrip() throws {
+ let event = try makeEvent()
+ for direction in [NostrCarrierPacket.Direction.toGateway, .fromGateway] {
+ let packet = try #require(NostrCarrierPacket(direction: direction, geohash: "u4pruy", event: event))
+ let encoded = try #require(packet.encode())
+ let decoded = try #require(NostrCarrierPacket.decode(encoded))
+
+ #expect(decoded == packet)
+ #expect(decoded.direction == direction)
+ #expect(decoded.geohash == "u4pruy")
+
+ // The carried event survives byte-exact: same ID, and the
+ // signature still verifies after the mesh hop.
+ let carried = try #require(decoded.event())
+ #expect(carried.id == event.id)
+ #expect(carried.sig == event.sig)
+ #expect(carried.isValidSignature())
+ }
+ }
+
+ @Test("rejects an oversized event at construction and at decode")
+ func oversizedRejected() throws {
+ let oversized = Data(repeating: 0x7B, count: NostrCarrierPacket.maxEventJSONBytes + 1)
+ #expect(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", eventJSON: oversized) == nil)
+
+ // Hand-build the TLV bytes to bypass the initializer's cap.
+ var data = Data([0x01, 0x00, 0x01, NostrCarrierPacket.Direction.toGateway.rawValue])
+ let geohash = Data("u4pruy".utf8)
+ data.append(contentsOf: [0x02, 0x00, UInt8(geohash.count)])
+ data.append(geohash)
+ data.append(contentsOf: [0x03, UInt8((oversized.count >> 8) & 0xFF), UInt8(oversized.count & 0xFF)])
+ data.append(oversized)
+ #expect(NostrCarrierPacket.decode(data) == nil)
+ }
+
+ @Test("rejects an over-length or empty geohash")
+ func geohashBoundsEnforced() throws {
+ let event = try makeEvent()
+ #expect(NostrCarrierPacket(direction: .toGateway, geohash: "", event: event) == nil)
+ #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 13), event: event) == nil)
+ #expect(NostrCarrierPacket(direction: .toGateway, geohash: String(repeating: "u", count: 12), event: event) != nil)
+ }
+
+ @Test("skips unknown TLVs for forward compatibility")
+ func unknownTLVSkipped() throws {
+ let event = try makeEvent()
+ let packet = try #require(NostrCarrierPacket(direction: .fromGateway, geohash: "u4pruy", event: event))
+ var encoded = try #require(packet.encode())
+ // Append an unknown TLV (type 0x7F, 2-byte value).
+ encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
+ let decoded = try #require(NostrCarrierPacket.decode(encoded))
+ #expect(decoded == packet)
+ }
+
+ @Test("rejects truncated and missing-field payloads")
+ func malformedRejected() throws {
+ let event = try makeEvent()
+ let packet = try #require(NostrCarrierPacket(direction: .toGateway, geohash: "u4pruy", event: event))
+ let encoded = try #require(packet.encode())
+
+ // Truncation anywhere inside the last TLV fails cleanly.
+ #expect(NostrCarrierPacket.decode(encoded.dropLast(1)) == nil)
+ #expect(NostrCarrierPacket.decode(encoded.prefix(4)) == nil)
+ #expect(NostrCarrierPacket.decode(Data()) == nil)
+
+ // Direction TLV alone (missing geohash and event) fails.
+ #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x01])) == nil)
+ // Unknown direction value fails.
+ #expect(NostrCarrierPacket.decode(Data([0x01, 0x00, 0x01, 0x77])) == nil)
+ }
+}
diff --git a/bitchatTests/Protocols/PacketsTests.swift b/bitchatTests/Protocols/PacketsTests.swift
index 78c244de..2368925a 100644
--- a/bitchatTests/Protocols/PacketsTests.swift
+++ b/bitchatTests/Protocols/PacketsTests.swift
@@ -1,3 +1,4 @@
+import BitFoundation
import Foundation
import Testing
@@ -108,6 +109,42 @@ struct PacketsTests {
#expect(decoded.directNeighbors == nil)
}
+ @Test
+ func announcementPacketRoundTripsCapabilities() throws {
+ let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics]
+ let packet = AnnouncementPacket(
+ nickname: "alice",
+ noisePublicKey: Data(repeating: 0x11, count: 32),
+ signingPublicKey: Data(repeating: 0x22, count: 32),
+ directNeighbors: nil,
+ capabilities: capabilities
+ )
+
+ let encoded = try #require(packet.encode())
+ let decoded = try #require(AnnouncementPacket.decode(from: encoded))
+ #expect(decoded.capabilities == capabilities)
+ }
+
+ @Test
+ func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws {
+ let legacy = try #require(
+ AnnouncementPacket(
+ nickname: "alice",
+ noisePublicKey: Data(repeating: 0x11, count: 32),
+ signingPublicKey: Data(repeating: 0x22, count: 32),
+ directNeighbors: nil
+ ).encode()
+ )
+ // The TLV is emitted only when capabilities are set, so legacy peers
+ // (and this packet) decode as nil rather than empty.
+ #expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil)
+
+ var withFutureBits = legacy
+ withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01])))
+ let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits))
+ #expect(decoded.capabilities?.rawValue == 0x0180)
+ }
+
@Test
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
let unknownTLV = Data([0x7F, 0x01, 0x41])
diff --git a/bitchatTests/Protocols/VouchAttestationTests.swift b/bitchatTests/Protocols/VouchAttestationTests.swift
new file mode 100644
index 00000000..064cf4d8
--- /dev/null
+++ b/bitchatTests/Protocols/VouchAttestationTests.swift
@@ -0,0 +1,176 @@
+import CryptoKit
+import Foundation
+import Testing
+
+@testable import bitchat
+
+struct VouchAttestationTests {
+ private let voucherKey = Curve25519.Signing.PrivateKey()
+
+ private func makeAttestation(
+ fingerprint: Data = Data(repeating: 0xAA, count: 32),
+ signingKey: Data = Data(repeating: 0xBB, count: 32),
+ timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
+ signedBy key: Curve25519.Signing.PrivateKey? = nil
+ ) throws -> VouchAttestation {
+ let signer = key ?? voucherKey
+ return try #require(
+ VouchAttestation.build(
+ voucheeFingerprint: fingerprint,
+ voucheeSigningKey: signingKey,
+ timestampMs: timestampMs,
+ sign: { try? signer.signature(for: $0) }
+ )
+ )
+ }
+
+ @Test
+ func roundTripsAndVerifiesSignature() throws {
+ let attestation = try makeAttestation()
+ let encoded = try #require(attestation.encode())
+ let decoded = try #require(VouchAttestation.decode(from: encoded))
+
+ #expect(decoded == attestation)
+ #expect(decoded.voucheeFingerprintHex == String(repeating: "aa", count: 32))
+ #expect(decoded.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
+ }
+
+ @Test
+ func decodeSkipsUnknownTLVsAndRejectsMalformedInput() throws {
+ let attestation = try makeAttestation()
+ var encoded = try #require(attestation.encode())
+
+ // Unknown TLV appended: skipped for forward compatibility.
+ encoded.append(contentsOf: [0x7F, 0x02, 0x01, 0x02])
+ #expect(VouchAttestation.decode(from: encoded) == attestation)
+
+ // Truncation and missing fields are rejected.
+ #expect(VouchAttestation.decode(from: encoded.dropLast()) == nil)
+ #expect(VouchAttestation.decode(from: Data([0x01, 0x20])) == nil)
+ #expect(VouchAttestation.decode(from: Data()) == nil)
+
+ // Wrong field sizes are rejected.
+ var wrongSize = Data([0x01, 0x10])
+ wrongSize.append(Data(repeating: 0xAA, count: 16))
+ #expect(VouchAttestation.decode(from: wrongSize) == nil)
+ }
+
+ @Test
+ func buildRejectsWrongKeyAndFingerprintSizes() {
+ let sign: (Data) -> Data? = { try? self.voucherKey.signature(for: $0) }
+ #expect(VouchAttestation.build(
+ voucheeFingerprint: Data(repeating: 0xAA, count: 16),
+ voucheeSigningKey: Data(repeating: 0xBB, count: 32),
+ sign: sign
+ ) == nil)
+ #expect(VouchAttestation.build(
+ voucheeFingerprint: Data(repeating: 0xAA, count: 32),
+ voucheeSigningKey: Data(repeating: 0xBB, count: 16),
+ sign: sign
+ ) == nil)
+ }
+
+ @Test
+ func forgedSignatureFailsVerification() throws {
+ let attestation = try makeAttestation()
+ let otherKey = Curve25519.Signing.PrivateKey()
+
+ // Verifying against a key that didn't sign fails.
+ #expect(!attestation.verifySignature(voucherSigningKey: otherKey.publicKey.rawRepresentation))
+
+ // An attestation signed by an imposter fails against the real key.
+ let forged = try makeAttestation(signedBy: otherKey)
+ #expect(!forged.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
+ #expect(!attestation.verifySignature(voucherSigningKey: Data(repeating: 0x01, count: 3)))
+ }
+
+ @Test
+ func tamperedFieldsFailVerification() throws {
+ let attestation = try makeAttestation()
+ let publicKey = voucherKey.publicKey.rawRepresentation
+
+ var tamperedFingerprint = attestation.voucheeFingerprint
+ tamperedFingerprint[0] ^= 0xFF
+ let tampered = VouchAttestation(
+ voucheeFingerprint: tamperedFingerprint,
+ voucheeSigningKey: attestation.voucheeSigningKey,
+ timestampMs: attestation.timestampMs,
+ signature: attestation.signature
+ )
+ #expect(!tampered.verifySignature(voucherSigningKey: publicKey))
+
+ let backdated = VouchAttestation(
+ voucheeFingerprint: attestation.voucheeFingerprint,
+ voucheeSigningKey: attestation.voucheeSigningKey,
+ timestampMs: attestation.timestampMs - 1,
+ signature: attestation.signature
+ )
+ #expect(!backdated.verifySignature(voucherSigningKey: publicKey))
+ }
+
+ @Test
+ func expiryWindowIsEnforced() throws {
+ let now = Date()
+ let fresh = try makeAttestation(timestampMs: UInt64(now.timeIntervalSince1970 * 1000))
+ #expect(!fresh.isExpired(now: now))
+
+ let thirtyOneDaysAgo = now.addingTimeInterval(-31 * 24 * 60 * 60)
+ let expired = try makeAttestation(timestampMs: UInt64(thirtyOneDaysAgo.timeIntervalSince1970 * 1000))
+ #expect(expired.isExpired(now: now))
+
+ let farFuture = now.addingTimeInterval(2 * 60 * 60)
+ let fromTheFuture = try makeAttestation(timestampMs: UInt64(farFuture.timeIntervalSince1970 * 1000))
+ #expect(fromTheFuture.isExpired(now: now))
+
+ // A verified-but-expired attestation still has a valid signature; the
+ // two checks are independent gates.
+ #expect(expired.verifySignature(voucherSigningKey: voucherKey.publicKey.rawRepresentation))
+ }
+
+ @Test
+ func batchRoundTripsAndEnforcesCap() throws {
+ let attestations = try (0..<3).map { index in
+ try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
+ }
+ let payload = try #require(VouchAttestation.encodeList(attestations))
+ #expect(VouchAttestation.decodeList(from: payload) == attestations)
+
+ #expect(VouchAttestation.encodeList([]) == nil)
+
+ let tooMany = try (0..<17).map { index in
+ try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
+ }
+ #expect(VouchAttestation.encodeList(tooMany) == nil)
+ }
+
+ @Test
+ func decodeListIgnoresEntriesBeyondCapAndMalformedEntries() throws {
+ let attestations = try (0..<17).map { index in
+ try makeAttestation(fingerprint: Data(repeating: UInt8(index + 1), count: 32))
+ }
+ // Hand-build an oversized batch that lies about its count.
+ var payload = Data([UInt8(attestations.count)])
+ for attestation in attestations {
+ let encoded = try #require(attestation.encode())
+ payload.append(UInt8(encoded.count >> 8))
+ payload.append(UInt8(encoded.count & 0xFF))
+ payload.append(encoded)
+ }
+ let decoded = VouchAttestation.decodeList(from: payload)
+ #expect(decoded.count == VouchAttestation.maxBatchCount)
+ #expect(decoded == Array(attestations.prefix(VouchAttestation.maxBatchCount)))
+
+ // A malformed middle entry is dropped without killing the batch.
+ let good = try makeAttestation()
+ let goodEncoded = try #require(good.encode())
+ var mixed = Data([2])
+ mixed.append(contentsOf: [0x00, 0x03, 0xDE, 0xAD, 0xBE])
+ mixed.append(UInt8(goodEncoded.count >> 8))
+ mixed.append(UInt8(goodEncoded.count & 0xFF))
+ mixed.append(goodEncoded)
+ #expect(VouchAttestation.decodeList(from: mixed) == [good])
+
+ #expect(VouchAttestation.decodeList(from: Data()) == [])
+ #expect(VouchAttestation.decodeList(from: Data([5])) == [])
+ }
+}
diff --git a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift
index ed7c66a1..a4423ec9 100644
--- a/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift
+++ b/bitchatTests/Services/BLEFragmentAssemblyBufferTests.swift
@@ -135,6 +135,140 @@ struct BLEFragmentAssemblyBufferTests {
}
}
+ @Test
+ func stalledBroadcastAssemblyReportsFragmentIDOnceUntilRetryLapses() throws {
+ var buffer = BLEFragmentAssemblyBuffer()
+ let fragmentID = Data((1...8).map { UInt8($0) })
+ let packet = makePacket(payload: makePayload(count: 256))
+ let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
+ let first = try #require(BLEFragmentHeader(packet: fragments[0]))
+
+ let t0 = Date(timeIntervalSince1970: 100)
+ _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
+
+ // Not yet stalled.
+ let early = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(4))
+ #expect(early.isEmpty)
+
+ // Stalled: reported once, big-endian stream ID.
+ let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
+ #expect(stalled == [fragmentID])
+
+ // Within the retry window: not re-reported.
+ let repeated = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(8))
+ #expect(repeated.isEmpty)
+
+ // After the retry window it is requested again.
+ let retried = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(17))
+ #expect(retried == [fragmentID])
+ }
+
+ @Test
+ func newFragmentResetsStallClockAndCompletionStopsRequests() throws {
+ var buffer = BLEFragmentAssemblyBuffer()
+ let fragmentID = Data((10...17).map { UInt8($0) })
+ let packet = makePacket(payload: makePayload(count: 384))
+ let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
+ let headers = try fragments.map { try #require(BLEFragmentHeader(packet: $0)) }
+ #expect(headers.count >= 3)
+
+ let t0 = Date(timeIntervalSince1970: 100)
+ _ = buffer.append(headers[0], maxInFlightAssemblies: 8, now: t0)
+ // A fragment arriving at t0+4 resets the stall clock.
+ _ = buffer.append(headers[1], maxInFlightAssemblies: 8, now: t0.addingTimeInterval(4))
+ let afterProgress = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
+ #expect(afterProgress.isEmpty)
+
+ // Completion removes the assembly entirely.
+ var result: BLEFragmentAssemblyBuffer.AppendResult?
+ for header in headers.dropFirst(2) {
+ result = buffer.append(header, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
+ }
+ guard case .complete = result else {
+ Issue.record("Expected assembly to complete")
+ return
+ }
+ let afterCompletion = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
+ #expect(afterCompletion.isEmpty)
+ }
+
+ @Test
+ func duplicateFragmentsDoNotResetStallClock() throws {
+ var buffer = BLEFragmentAssemblyBuffer()
+ let fragmentID = Data((20...27).map { UInt8($0) })
+ let packet = makePacket(payload: makePayload(count: 256))
+ let fragments = try makeFragments(for: packet, chunkSize: 128, fragmentID: fragmentID)
+ let first = try #require(BLEFragmentHeader(packet: fragments[0]))
+
+ let t0 = Date(timeIntervalSince1970: 100)
+ _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0)
+
+ // Relay duplicates of the same index arrive every few seconds; they
+ // bring no new data, so they must not keep the stream "fresh".
+ _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(3))
+ _ = buffer.append(first, maxInFlightAssemblies: 8, now: t0.addingTimeInterval(5))
+
+ let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(6))
+ #expect(stalled == [fragmentID])
+ }
+
+ @Test
+ func overflowStalledStreamsRotateAcrossPasses() throws {
+ var buffer = BLEFragmentAssemblyBuffer()
+ let cap = RequestSyncPacket.maxFragmentIdFilterCount
+ let streamCount = cap + 10
+ let t0 = Date(timeIntervalSince1970: 100)
+
+ // Incomplete broadcast assemblies with staggered last-fragment times
+ // (stream 0 is the oldest stall).
+ var ids: [Data] = []
+ for i in 0..> 8), UInt8(i & 0xFF)])
+ ids.append(fragmentID)
+ let header = try #require(BLEFragmentHeader(packet: makeFragmentPacket(
+ fragmentID: fragmentID,
+ index: 0,
+ total: 2,
+ originalType: MessageType.message.rawValue,
+ fragmentData: Data([0x01])
+ )))
+ _ = buffer.append(header, maxInFlightAssemblies: streamCount, now: t0.addingTimeInterval(Double(i)))
+ }
+
+ // All streams are stalled; only the cap's worth (oldest first) is
+ // requested and rate-limited, the overflow stays eligible.
+ let firstPassAt = t0.addingTimeInterval(Double(streamCount) + 5)
+ let firstPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt)
+ #expect(firstPass == Array(ids.prefix(cap)))
+
+ // Next pass picks up exactly the overflow streams.
+ let secondPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(1))
+ #expect(secondPass == Array(ids.suffix(streamCount - cap)))
+
+ // Nothing left until a retry window lapses.
+ let thirdPass = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 60, now: firstPassAt.addingTimeInterval(2))
+ #expect(thirdPass.isEmpty)
+ }
+
+ @Test
+ func directedAssembliesAreNeverReportedAsStalled() throws {
+ var buffer = BLEFragmentAssemblyBuffer()
+ let fragment = makeFragmentPacket(
+ fragmentID: Data(repeating: 0x0A, count: 8),
+ index: 0,
+ total: 2,
+ originalType: MessageType.message.rawValue,
+ fragmentData: Data([0x01]),
+ recipientID: Data(hexString: "0102030405060708")
+ )
+ let header = try #require(BLEFragmentHeader(packet: fragment))
+
+ let t0 = Date(timeIntervalSince1970: 100)
+ _ = buffer.append(header, maxInFlightAssemblies: 8, now: t0)
+ let stalled = buffer.stalledBroadcastFragmentIDs(stalledAfter: 5, retryAfter: 10, now: t0.addingTimeInterval(60))
+ #expect(stalled.isEmpty)
+ }
+
private func makePacket(payload: Data, timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
diff --git a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift
index af8c2777..75550bc5 100644
--- a/bitchatTests/Services/BLEIngressLinkRegistryTests.swift
+++ b/bitchatTests/Services/BLEIngressLinkRegistryTests.swift
@@ -105,6 +105,41 @@ struct BLEIngressLinkRegistryTests {
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
}
+ @Test
+ func packetContextRejectsRequestSyncSenderMismatchOnBoundLink() {
+ let localPeer = PeerID(str: "0011223344556677")
+ let boundPeer = PeerID(str: "1122334455667788")
+ let claimedPeer = PeerID(str: "8899aabbccddeeff")
+ let packet = makeRequestSyncPacket(sender: claimedPeer)
+
+ let result = BLEIngressLinkRegistry.packetContext(
+ for: packet,
+ claimedSenderID: claimedPeer,
+ boundPeerID: boundPeer,
+ localPeerID: localPeer,
+ directAnnounceTTL: 7
+ )
+
+ #expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
+ }
+
+ @Test
+ func packetContextAllowsRequestSyncFromBoundPeer() throws {
+ let localPeer = PeerID(str: "0011223344556677")
+ let boundPeer = PeerID(str: "1122334455667788")
+ let packet = makeRequestSyncPacket(sender: boundPeer)
+
+ let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
+ for: packet,
+ claimedSenderID: boundPeer,
+ boundPeerID: boundPeer,
+ localPeerID: localPeer,
+ directAnnounceTTL: 7
+ )))
+
+ #expect(context.receivedFromPeerID == boundPeer)
+ }
+
@Test
func packetContextUsesBoundPeerForRSRValidation() throws {
let localPeer = PeerID(str: "0011223344556677")
@@ -158,6 +193,18 @@ private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket {
)
}
+private func makeRequestSyncPacket(sender: PeerID) -> BitchatPacket {
+ BitchatPacket(
+ type: MessageType.requestSync.rawValue,
+ senderID: Data(hexString: sender.id) ?? Data(),
+ recipientID: nil,
+ timestamp: 1,
+ payload: Data(),
+ signature: nil,
+ ttl: 0
+ )
+}
+
private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket {
BitchatPacket(
type: MessageType.announce.rawValue,
diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
index 056d3ca9..74a4441d 100644
--- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
+++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
@@ -100,11 +100,11 @@ struct BLEPublicMessageHandlerTests {
@Test
func staleBroadcastIsDropped() {
- let now = Date(timeIntervalSince1970: 1_000)
+ let now = Date(timeIntervalSince1970: 1_000_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder, now: now)
- let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
+ let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000)
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
handler.handle(packet, from: remotePeerID)
diff --git a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift
index fc725194..dc6d7271 100644
--- a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift
+++ b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift
@@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests {
@Test
func staleBroadcastIsRejectedWithAge() {
- let now = Date(timeIntervalSince1970: 1_000)
+ // The acceptance window matches the gossip public-history window.
+ let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1
+ let now = Date(timeIntervalSince1970: 1_000_000)
let sender = PeerID(str: "8877665544332211")
let packet = makePacket(
sender: sender,
- timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000),
+ timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000),
recipientID: nil
)
@@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests {
now: now
)
- #expect(decision == .reject(.staleBroadcast(ageSeconds: 901)))
+ #expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge)))
}
@Test
diff --git a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift
index e6bfce05..7e580098 100644
--- a/bitchatTests/Services/BLERouteForwardingPolicyTests.swift
+++ b/bitchatTests/Services/BLERouteForwardingPolicyTests.swift
@@ -112,6 +112,36 @@ struct BLERouteForwardingPolicyTests {
#expect(plan.nextHop == nil)
}
+ @Test("REQUEST_SYNC is never route-forwarded even with a route and TTL headroom")
+ func requestSyncNeverRouteForwarded() {
+ let previous = peer("1111111111111111")
+ let local = peer("2222222222222222")
+ let nextHop = peer("3333333333333333")
+ let destination = peer("4444444444444444")
+ var packet = makePacket(
+ sender: previous,
+ recipient: destination,
+ ttl: 7,
+ route: [routeData(local), routeData(nextHop)]
+ )
+ packet = BitchatPacket(
+ type: MessageType.requestSync.rawValue,
+ senderID: packet.senderID,
+ recipientID: packet.recipientID,
+ timestamp: packet.timestamp,
+ payload: packet.payload,
+ signature: nil,
+ ttl: packet.ttl,
+ route: packet.route
+ )
+
+ let plan = forwardingPlan(packet, local: local, connected: [nextHop])
+
+ #expect(plan.shouldSuppressFloodRelay)
+ #expect(plan.forwardPacket == nil)
+ #expect(plan.nextHop == nil)
+ }
+
private func forwardingPlan(
_ packet: BitchatPacket,
local: PeerID,
diff --git a/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift
new file mode 100644
index 00000000..5d3315f6
--- /dev/null
+++ b/bitchatTests/Services/BLESourceRouteFailureCacheTests.swift
@@ -0,0 +1,98 @@
+//
+// BLESourceRouteFailureCacheTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import BitFoundation
+@testable import bitchat
+
+struct BLESourceRouteFailureCacheTests {
+ private let recipient = PeerID(str: "0102030405060708")
+ private let config = BLESourceRouteFailureCache.Config(
+ confirmationWindowSeconds: 10,
+ suppressionSeconds: 60
+ )
+
+ private func attempts(_ cache: inout BLESourceRouteFailureCache, at date: Date) -> Bool {
+ cache.shouldAttemptRoute(to: recipient, now: date)
+ }
+
+ @Test func allowsRoutingByDefault() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ #expect(attempts(&cache, at: Date()))
+ }
+
+ @Test func unconfirmedRoutedSendSuppressesRouting() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ let t0 = Date()
+
+ cache.noteRoutedSend(to: recipient, now: t0)
+ // Inside the confirmation window: keep routing.
+ #expect(attempts(&cache, at: t0.addingTimeInterval(5)))
+ // Past the window with no inbound traffic: route failed, flood.
+ #expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
+ // Still suppressed for the suppression TTL.
+ #expect(!attempts(&cache, at: t0.addingTimeInterval(40)))
+ // Suppression lapses: routing may be attempted again.
+ #expect(attempts(&cache, at: t0.addingTimeInterval(11 + 61)))
+ }
+
+ @Test func inboundActivityConfirmsPendingSend() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ let t0 = Date()
+
+ cache.noteRoutedSend(to: recipient, now: t0)
+ cache.noteInboundActivity(from: recipient)
+ // Confirmed: no suppression even long after the window.
+ #expect(attempts(&cache, at: t0.addingTimeInterval(30)))
+ }
+
+ @Test func inboundActivityDoesNotLiftActiveSuppression() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ let t0 = Date()
+
+ cache.noteRoutedSend(to: recipient, now: t0)
+ // Trip the failure → suppression starts at t0+15.
+ #expect(!attempts(&cache, at: t0.addingTimeInterval(15)))
+ // Inbound traffic may have arrived via flood; suppression holds.
+ cache.noteInboundActivity(from: recipient)
+ #expect(!attempts(&cache, at: t0.addingTimeInterval(20)))
+ #expect(attempts(&cache, at: t0.addingTimeInterval(15 + 61)))
+ }
+
+ @Test func backToBackSendsShareOneDeadline() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ let t0 = Date()
+
+ cache.noteRoutedSend(to: recipient, now: t0)
+ cache.noteRoutedSend(to: recipient, now: t0.addingTimeInterval(8))
+ // Deadline runs from the first unconfirmed send.
+ #expect(!attempts(&cache, at: t0.addingTimeInterval(11)))
+ }
+
+ @Test func pruneDropsExpiredEntries() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ let t0 = Date()
+
+ cache.noteRoutedSend(to: recipient, now: t0)
+ // Past confirmation + suppression: the entry can no longer matter.
+ cache.prune(now: t0.addingTimeInterval(75))
+ #expect(attempts(&cache, at: t0.addingTimeInterval(76)))
+ }
+
+ @Test func pruneKeepsEntriesThatStillMatter() {
+ var cache = BLESourceRouteFailureCache(config: config)
+ let t0 = Date()
+
+ cache.noteRoutedSend(to: recipient, now: t0)
+ cache.prune(now: t0.addingTimeInterval(30))
+ // The unconverted pending entry survives pruning and still converts
+ // into a suppression on the next routing decision.
+ #expect(!attempts(&cache, at: t0.addingTimeInterval(31)))
+ }
+}
diff --git a/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift
new file mode 100644
index 00000000..c762a0c7
--- /dev/null
+++ b/bitchatTests/Services/BLESourceRouteOriginationPolicyTests.swift
@@ -0,0 +1,98 @@
+//
+// BLESourceRouteOriginationPolicyTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import BitFoundation
+@testable import bitchat
+
+struct BLESourceRouteOriginationPolicyTests {
+ private let localPeerIDData = Data(hexString: "0102030405060708")!
+ private let recipient = PeerID(str: "1112131415161718")
+ private let hop = Data(hexString: "2122232425262728")!
+
+ private func makePacket(
+ senderID: Data? = nil,
+ recipientID: Data? = Data(hexString: "1112131415161718"),
+ ttl: UInt8 = 7
+ ) -> BitchatPacket {
+ BitchatPacket(
+ type: MessageType.noiseEncrypted.rawValue,
+ senderID: senderID ?? localPeerIDData,
+ recipientID: recipientID,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data([0x01]),
+ signature: nil,
+ ttl: ttl
+ )
+ }
+
+ private func route(
+ packet: BitchatPacket,
+ isRecipientConnected: Bool = false,
+ shouldAttemptRoute: Bool = true,
+ computedRoute: [Data]? = nil
+ ) -> [Data]? {
+ BLESourceRouteOriginationPolicy.route(
+ for: packet,
+ to: recipient,
+ localPeerIDData: localPeerIDData,
+ isRecipientConnected: { _ in isRecipientConnected },
+ shouldAttemptRoute: { _ in shouldAttemptRoute },
+ computeRoute: { _ in computedRoute ?? [self.hop] }
+ )
+ }
+
+ @Test func routesWhenAllGatesPass() {
+ #expect(route(packet: makePacket()) == [hop])
+ }
+
+ @Test func relayedPacketNeverGetsRoute() {
+ let relayed = makePacket(senderID: Data(hexString: "aabbccddeeff0011"))
+ #expect(route(packet: relayed) == nil)
+ }
+
+ @Test func broadcastRecipientNeverGetsRoute() {
+ let broadcast = makePacket(recipientID: Data(repeating: 0xFF, count: 8))
+ #expect(route(packet: broadcast) == nil)
+ let noRecipient = makePacket(recipientID: nil)
+ #expect(route(packet: noRecipient) == nil)
+ }
+
+ @Test func linkLocalTTLNeverGetsRoute() {
+ // TTL 0/1 packets (e.g. REQUEST_SYNC) cannot traverse hops.
+ #expect(route(packet: makePacket(ttl: 0)) == nil)
+ #expect(route(packet: makePacket(ttl: 1)) == nil)
+ }
+
+ @Test func directlyConnectedRecipientNeverGetsRoute() {
+ #expect(route(packet: makePacket(), isRecipientConnected: true) == nil)
+ }
+
+ @Test func suppressedRecipientFallsBackToFlood() {
+ #expect(route(packet: makePacket(), shouldAttemptRoute: false) == nil)
+ }
+
+ @Test func missingOrEmptyRouteFallsBackToFlood() {
+ var sawComputeRoute = false
+ let result = BLESourceRouteOriginationPolicy.route(
+ for: makePacket(),
+ to: recipient,
+ localPeerIDData: localPeerIDData,
+ isRecipientConnected: { _ in false },
+ shouldAttemptRoute: { _ in true },
+ computeRoute: { _ in
+ sawComputeRoute = true
+ return nil
+ }
+ )
+ #expect(result == nil)
+ #expect(sawComputeRoute)
+ #expect(route(packet: makePacket(), computedRoute: []) == nil)
+ }
+}
diff --git a/bitchatTests/Services/BoardAlertsModelTests.swift b/bitchatTests/Services/BoardAlertsModelTests.swift
new file mode 100644
index 00000000..df891cea
--- /dev/null
+++ b/bitchatTests/Services/BoardAlertsModelTests.swift
@@ -0,0 +1,215 @@
+//
+// BoardAlertsModelTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Combine
+import Foundation
+import Testing
+@testable import bitchat
+
+@MainActor
+struct BoardAlertsModelTests {
+
+ private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+ private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
+ private let ownKey = Data(repeating: 7, count: 32)
+
+ private final class Harness {
+ var lines: [(content: String, geohash: String)] = []
+ var pendingFlushes: [@MainActor () -> Void] = []
+
+ @MainActor
+ func flushAll() {
+ let flushes = pendingFlushes
+ pendingFlushes = []
+ for flush in flushes { flush() }
+ }
+ }
+
+ private func makeModel(harness: Harness, now: Date? = nil) -> BoardAlertsModel {
+ let fixedNow = now ?? baseDate
+ return BoardAlertsModel(
+ arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
+ dependencies: BoardAlertsModel.Dependencies(
+ isOwnPost: { [ownKey] in $0.authorSigningKey == ownKey },
+ emitSystemLine: { content, geohash in
+ harness.lines.append((content, geohash))
+ },
+ now: { fixedNow },
+ scheduleFlush: { flush in
+ harness.pendingFlushes.append(flush)
+ }
+ )
+ )
+ }
+
+ private func makePost(
+ content: String = "hello",
+ geohash: String = "9q8yy",
+ nickname: String = "alice",
+ createdAt: UInt64? = nil,
+ urgent: Bool = false,
+ authorKey: Data = Data(repeating: 1, count: 32),
+ postID: Data? = nil
+ ) -> BoardPostPacket {
+ BoardPostPacket(
+ postID: postID ?? Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
+ geohash: geohash,
+ content: content,
+ authorSigningKey: authorKey,
+ authorNickname: nickname,
+ createdAt: createdAt ?? baseMs,
+ expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
+ flags: urgent ? BoardPostPacket.urgentFlag : 0,
+ signature: Data(repeating: 2, count: 64)
+ )
+ }
+
+ @Test
+ func ownPosts_neverBadgeOrAlert() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(urgent: true, authorKey: ownKey))
+ harness.flushAll()
+
+ #expect(model.unseenCount(forGeohash: "9q8yy") == 0)
+ #expect(harness.lines.isEmpty)
+ }
+
+ @Test
+ func routinePost_badgesWithoutChatLine() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(geohash: ""))
+ harness.flushAll()
+
+ #expect(model.unseenCount(forGeohash: "") == 1)
+ #expect(model.unseenCount(forGeohash: "9q8yy") == 0)
+ #expect(harness.lines.isEmpty)
+ }
+
+ @Test
+ func urgentRecentPost_emitsLineInMatchingScope() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(content: "road closed", geohash: "9q8yy", urgent: true))
+ #expect(harness.lines.isEmpty)
+ harness.flushAll()
+
+ #expect(harness.lines.count == 1)
+ #expect(harness.lines[0].geohash == "9q8yy")
+ #expect(harness.lines[0].content.contains("road closed"))
+ #expect(harness.lines[0].content.contains("@alice"))
+ }
+
+ @Test
+ func urgentBackfilledPost_badgesOnly() {
+ let harness = Harness()
+ let arrivalTime = baseDate.addingTimeInterval(BoardAlertsModel.inlineRecencyWindow + 120)
+ let model = makeModel(harness: harness, now: arrivalTime)
+
+ model.handleArrival(makePost(createdAt: baseMs, urgent: true))
+ harness.flushAll()
+
+ #expect(model.unseenCount(forGeohash: "9q8yy") == 1)
+ #expect(harness.lines.isEmpty)
+ }
+
+ @Test
+ func simultaneousUrgentPosts_collapseIntoOneLine() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(content: "one", urgent: true))
+ model.handleArrival(makePost(content: "two", urgent: true))
+ model.handleArrival(makePost(content: "three", urgent: true))
+ harness.flushAll()
+
+ #expect(harness.lines.count == 1)
+ #expect(harness.lines[0].content.contains("3"))
+ #expect(harness.pendingFlushes.isEmpty)
+ }
+
+ @Test
+ func urgentPostsInDifferentScopes_alertEachScope() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(content: "geo pin", geohash: "9q8yy", urgent: true))
+ model.handleArrival(makePost(content: "mesh pin", geohash: "", urgent: true))
+ harness.flushAll()
+
+ #expect(harness.lines.count == 2)
+ #expect(Set(harness.lines.map(\.geohash)) == ["9q8yy", ""])
+ }
+
+ @Test
+ func duplicateArrival_isHandledOnce() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+ let id = Data(repeating: 3, count: 16)
+
+ model.handleArrival(makePost(urgent: true, postID: id))
+ model.handleArrival(makePost(urgent: true, postID: id))
+ harness.flushAll()
+
+ #expect(model.unseenCount(forGeohash: "9q8yy") == 1)
+ #expect(harness.lines.count == 1)
+ #expect(!harness.lines[0].content.contains("2"))
+ }
+
+ @Test
+ func markSeen_clearsOnlyVisibleScopes() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(geohash: ""))
+ model.handleArrival(makePost(geohash: "9q8yy"))
+ model.handleArrival(makePost(geohash: "u4pruyd"))
+
+ // Opening the sheet on mesh + 9q8yy must not eat the badge for the
+ // never-shown u4pruyd channel.
+ model.markSeen(forScopes: ["", "9q8yy"])
+
+ #expect(model.unseenCount(forGeohash: "") == 0)
+ #expect(model.unseenCount(forGeohash: "9q8yy") == 0)
+ #expect(model.unseenCount(forGeohash: "u4pruyd") == 1)
+ }
+
+ @Test
+ func reset_dropsPendingUrgentLinesAndBadges() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+
+ model.handleArrival(makePost(content: "pre-wipe secret", urgent: true))
+ #expect(harness.pendingFlushes.count == 1)
+
+ // Panic wipe lands before the collapse flush fires.
+ model.reset()
+ harness.flushAll()
+
+ #expect(harness.lines.isEmpty)
+ #expect(model.unseenCount(forGeohash: "9q8yy") == 0)
+ }
+
+ @Test
+ func longUrgentContent_isTruncatedInLine() {
+ let harness = Harness()
+ let model = makeModel(harness: harness)
+ let long = String(repeating: "a", count: 400)
+
+ model.handleArrival(makePost(content: long, urgent: true))
+ harness.flushAll()
+
+ #expect(harness.lines.count == 1)
+ #expect(harness.lines[0].content.count < 200)
+ #expect(harness.lines[0].content.contains("…"))
+ }
+}
diff --git a/bitchatTests/Services/BoardStoreTests.swift b/bitchatTests/Services/BoardStoreTests.swift
new file mode 100644
index 00000000..d501de39
--- /dev/null
+++ b/bitchatTests/Services/BoardStoreTests.swift
@@ -0,0 +1,385 @@
+//
+// BoardStoreTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitFoundation
+import CryptoKit
+import Foundation
+import Testing
+@testable import bitchat
+
+struct BoardStoreTests {
+
+ private final class MutableClock: @unchecked Sendable {
+ var now: Date
+ init(now: Date) { self.now = now }
+ }
+
+ private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+ private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
+
+ private func makeStore(clock: MutableClock, fileURL: URL? = nil) -> BoardStore {
+ BoardStore(persistsToDisk: fileURL != nil, fileURL: fileURL, now: { clock.now })
+ }
+
+ private func tempFileURL() -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("board-store-\(UUID().uuidString).json")
+ }
+
+ private func makePost(
+ author: Curve25519.Signing.PrivateKey,
+ geohash: String = "9q8yy",
+ content: String = "note",
+ createdAt: UInt64,
+ lifetimeMs: UInt64 = 24 * 60 * 60 * 1000
+ ) throws -> (wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket) {
+ let postID = Data((0..<16).map { _ in UInt8.random(in: 0...255) })
+ let key = author.publicKey.rawRepresentation
+ let expiresAt = createdAt + lifetimeMs
+ let signingBytes = BoardPostPacket.signingBytes(
+ postID: postID,
+ geohash: geohash,
+ content: content,
+ authorSigningKey: key,
+ authorNickname: "tester",
+ createdAt: createdAt,
+ expiresAt: expiresAt,
+ flags: 0
+ )
+ let post = BoardPostPacket(
+ postID: postID,
+ geohash: geohash,
+ content: content,
+ authorSigningKey: key,
+ authorNickname: "tester",
+ createdAt: createdAt,
+ expiresAt: expiresAt,
+ flags: 0,
+ signature: try author.signature(for: signingBytes)
+ )
+ let wire = BoardWire.post(post)
+ return (wire, makePacket(payload: wire.encode(), timestamp: createdAt), post)
+ }
+
+ private func makeTombstone(
+ for post: BoardPostPacket,
+ author: Curve25519.Signing.PrivateKey,
+ deletedAt: UInt64,
+ claimKey: Data? = nil
+ ) throws -> (wire: BoardWire, packet: BitchatPacket) {
+ let tombstone = BoardTombstonePacket(
+ postID: post.postID,
+ authorSigningKey: claimKey ?? author.publicKey.rawRepresentation,
+ deletedAt: deletedAt,
+ signature: try author.signature(for: BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt))
+ )
+ let wire = BoardWire.tombstone(tombstone)
+ return (wire, makePacket(payload: wire.encode(), timestamp: deletedAt))
+ }
+
+ private func makePacket(payload: Data, timestamp: UInt64) -> BitchatPacket {
+ BitchatPacket(
+ type: MessageType.boardPost.rawValue,
+ senderID: Data((0..<8).map { _ in UInt8.random(in: 0...255) }),
+ recipientID: nil,
+ timestamp: timestamp,
+ payload: payload,
+ signature: nil,
+ ttl: 7
+ )
+ }
+
+ // MARK: - Ingest basics
+
+ @Test func ingestStoresAndDeduplicates() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs)
+
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .duplicate)
+ #expect(store.posts(forGeohash: "9q8yy").count == 1)
+ #expect(store.posts(forGeohash: "").isEmpty)
+ #expect(store.syncCandidates().count == 1)
+ }
+
+ @Test func rejectsAlreadyExpiredPost() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs - 2 * 60 * 60 * 1000, lifetimeMs: 60 * 60 * 1000)
+
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
+ #expect(store.posts(forGeohash: "9q8yy").isEmpty)
+ }
+
+ // MARK: - Receive-time timestamp policy
+
+ @Test func rejectsPostCreatedBeyondClockSkew() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs + 60 * 1000)
+
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
+ #expect(store.posts(forGeohash: "9q8yy").isEmpty)
+ }
+
+ @Test func acceptsPostCreatedWithinClockSkew() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs + BoardStore.Limits.clockSkewMs - 60 * 1000)
+
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
+ #expect(store.posts(forGeohash: "9q8yy").count == 1)
+ }
+
+ @Test func rejectsPostExpiringTooFarInTheFuture() throws {
+ // Builds the wire directly, bypassing the decoder's span check, to
+ // exercise the ingest-level expiresAt bound on its own.
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 30 * 24 * 60 * 60 * 1000)
+
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
+ #expect(store.posts(forGeohash: "9q8yy").isEmpty)
+ }
+
+ // MARK: - Caps and eviction
+
+ @Test func perAuthorCapEvictsOldest() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+
+ var oldestID: Data?
+ for index in 0..<(BoardStore.Limits.maxPostsPerAuthor + 1) {
+ let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
+ if index == 0 { oldestID = entry.post.postID }
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
+ }
+
+ let posts = store.posts(forGeohash: "9q8yy")
+ #expect(posts.count == BoardStore.Limits.maxPostsPerAuthor)
+ #expect(!posts.contains { $0.postID == oldestID })
+ }
+
+ @Test func globalCapEvictsOldest() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+
+ var oldestID: Data?
+ var author = Curve25519.Signing.PrivateKey()
+ for index in 0..<(BoardStore.Limits.maxPosts + 1) {
+ if index % BoardStore.Limits.maxPostsPerAuthor == 0 {
+ author = Curve25519.Signing.PrivateKey()
+ }
+ let entry = try makePost(author: author, createdAt: baseMs + UInt64(index) * 1000)
+ if index == 0 { oldestID = entry.post.postID }
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .accepted)
+ }
+
+ let posts = store.posts(forGeohash: "9q8yy")
+ #expect(posts.count == BoardStore.Limits.maxPosts)
+ #expect(!posts.contains { $0.postID == oldestID })
+ }
+
+ // MARK: - Expiry sweep
+
+ @Test func expiredPostsAreSwept() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let shortLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 60 * 60 * 1000)
+ let longLived = try makePost(author: author, createdAt: baseMs, lifetimeMs: 48 * 60 * 60 * 1000)
+ store.ingest(shortLived.wire, packet: shortLived.packet)
+ store.ingest(longLived.wire, packet: longLived.packet)
+ #expect(store.posts(forGeohash: "9q8yy").count == 2)
+
+ clock.now = baseDate.addingTimeInterval(2 * 60 * 60) // 2h later
+ let remaining = store.posts(forGeohash: "9q8yy")
+ #expect(remaining.count == 1)
+ #expect(remaining.first?.postID == longLived.post.postID)
+ #expect(store.syncCandidates().count == 1)
+ }
+
+ // MARK: - Tombstones
+
+ @Test func tombstoneDeletesPostAndPropagatesUntilOriginalExpiry() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs, lifetimeMs: 24 * 60 * 60 * 1000)
+ store.ingest(entry.wire, packet: entry.packet)
+
+ let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
+ #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
+
+ // Post is gone, tombstone still syncs so the delete propagates.
+ #expect(store.posts(forGeohash: "9q8yy").isEmpty)
+ #expect(store.syncCandidates().count == 1)
+
+ // Replayed copy of the deleted post is refused.
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
+
+ // After the post's original expiry the tombstone is dropped too.
+ clock.now = baseDate.addingTimeInterval(25 * 60 * 60)
+ #expect(store.syncCandidates().isEmpty)
+ }
+
+ @Test func tombstoneFromWrongKeyIsRejected() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let attacker = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs)
+ store.ingest(entry.wire, packet: entry.packet)
+
+ // Attacker signs with their own key (self-consistent wire, so it
+ // passes signature verification) but targets the victim's post.
+ let forged = try makeTombstone(for: entry.post, author: attacker, deletedAt: baseMs + 1000)
+ #expect(store.ingest(forged.wire, packet: forged.packet) == .rejected)
+ #expect(store.posts(forGeohash: "9q8yy").count == 1)
+ }
+
+ @Test func tombstoneArrivingBeforePostSuppressesIt() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs)
+
+ let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
+ #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
+ #expect(store.ingest(entry.wire, packet: entry.packet) == .rejected)
+ #expect(store.posts(forGeohash: "9q8yy").isEmpty)
+ }
+
+ @Test func orphanTombstoneRetentionIsBoundedByReceiveTime() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+ let entry = try makePost(author: author, createdAt: baseMs) // never ingested
+
+ // Attacker-chosen far-future deletedAt must not extend retention.
+ let farFuture = baseMs + 365 * 24 * 60 * 60 * 1000
+ let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: farFuture)
+ #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
+ #expect(store.syncCandidates().count == 1)
+
+ // No post can outlive 7 days from receipt, so neither may an orphan
+ // tombstone (plus skew allowance).
+ clock.now = baseDate.addingTimeInterval(8 * 24 * 60 * 60)
+ #expect(store.syncCandidates().isEmpty)
+ }
+
+ @Test func orphanTombstonePerAuthorCapEvictsOldest() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+
+ var unseenPosts: [(wire: BoardWire, packet: BitchatPacket, post: BoardPostPacket)] = []
+ for index in 0..<(BoardStore.Limits.maxOrphanTombstonesPerAuthor + 1) {
+ let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
+ unseenPosts.append(entry)
+ let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
+ #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
+ }
+
+ #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstonesPerAuthor)
+ // The oldest orphan was evicted, so its post is no longer suppressed…
+ #expect(store.ingest(unseenPosts[0].wire, packet: unseenPosts[0].packet) == .accepted)
+ // …while the surviving orphans still suppress theirs.
+ #expect(store.ingest(unseenPosts[1].wire, packet: unseenPosts[1].packet) == .rejected)
+ }
+
+ @Test func orphanTombstoneGlobalCapEvictsOldest() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+
+ var author = Curve25519.Signing.PrivateKey()
+ for index in 0..<(BoardStore.Limits.maxOrphanTombstones + 1) {
+ if index % BoardStore.Limits.maxOrphanTombstonesPerAuthor == 0 {
+ author = Curve25519.Signing.PrivateKey()
+ }
+ let entry = try makePost(author: author, createdAt: baseMs + UInt64(index))
+ let tombstone = try makeTombstone(for: entry.post, author: author, deletedAt: baseMs + 1000)
+ #expect(store.ingest(tombstone.wire, packet: tombstone.packet) == .accepted)
+ }
+
+ #expect(store.syncCandidates().count == BoardStore.Limits.maxOrphanTombstones)
+ }
+
+ @Test func matchedTombstonesAreExemptFromOrphanCaps() throws {
+ let clock = MutableClock(now: baseDate)
+ let store = makeStore(clock: clock)
+ let author = Curve25519.Signing.PrivateKey()
+
+ // Post-then-delete more times than the per-author orphan cap; every
+ // tombstone matched a live post, so none may be evicted.
+ let cycles = BoardStore.Limits.maxOrphanTombstonesPerAuthor + 2
+ for index in 0..
+//
+
+import BitFoundation
+import Foundation
+import Testing
+@testable import bitchat
+
+@Suite("Gateway mode policy")
+@MainActor
+struct GatewayServiceTests {
+ private static let geohash = "u4pruy"
+
+ /// Closure-injected harness around `GatewayService` recording every
+ /// side effect, with a controllable clock and relay connectivity.
+ @MainActor
+ private final class Fixture {
+ private final class ClockBox {
+ var now = Date()
+ }
+
+ var relaysConnected = true
+ var currentGeohash: String? = GatewayServiceTests.geohash
+ var gatewayPeers: [PeerID] = []
+ var sendToGatewaySucceeds = true
+
+ private(set) var published: [(event: NostrEvent, geohash: String)] = []
+ private(set) var broadcasts: [Data] = []
+ private(set) var injected: [NostrEvent] = []
+ private(set) var uplinkSends: [(payload: Data, peer: PeerID)] = []
+ private(set) var enabledChanges: [Bool] = []
+ private(set) var scheduledDrains: [(delay: TimeInterval, work: @MainActor () -> Void)] = []
+
+ private let clock = ClockBox()
+ let defaults: UserDefaults
+ let service: GatewayService
+
+ init(enabled: Bool = true, suite: String = "GatewayServiceTests-\(UUID().uuidString)") {
+ defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ let clock = clock
+ service = GatewayService(defaults: defaults) { clock.now }
+ service.publishToRelays = { [weak self] event, geohash in
+ self?.published.append((event, geohash))
+ }
+ service.broadcastToMesh = { [weak self] payload in
+ self?.broadcasts.append(payload)
+ }
+ service.sendToGatewayPeer = { [weak self] payload, peer in
+ guard let self, self.sendToGatewaySucceeds else { return false }
+ self.uplinkSends.append((payload, peer))
+ return true
+ }
+ service.availableGatewayPeers = { [weak self] in self?.gatewayPeers ?? [] }
+ service.relaysConnected = { [weak self] in self?.relaysConnected ?? false }
+ service.currentGeohash = { [weak self] in self?.currentGeohash }
+ service.injectInbound = { [weak self] event in self?.injected.append(event) }
+ service.onEnabledChanged = { [weak self] enabled in self?.enabledChanges.append(enabled) }
+ // Capture drain timers instead of arming a real Task so the drain
+ // is deterministic under the fake clock.
+ service.scheduleDrainTimer = { [weak self] delay, work in
+ self?.scheduledDrains.append((delay, work))
+ }
+ if enabled {
+ service.setEnabled(true)
+ }
+ }
+
+ func advance(_ seconds: TimeInterval) {
+ clock.now = clock.now.addingTimeInterval(seconds)
+ }
+
+ /// Fires every currently-scheduled drain timer (simulating the window
+ /// freeing), as the real Task would after its delay.
+ func fireScheduledDrains() {
+ let due = scheduledDrains
+ scheduledDrains.removeAll()
+ for item in due { item.work() }
+ }
+ }
+
+ // MARK: Event helpers
+
+ private func makeEvent(
+ geohash: String = GatewayServiceTests.geohash,
+ content: String = "hello \(UUID().uuidString.prefix(8))"
+ ) throws -> NostrEvent {
+ let identity = try NostrIdentity.generate()
+ return try NostrProtocol.createEphemeralGeohashEvent(
+ content: content,
+ geohash: geohash,
+ senderIdentity: identity,
+ nickname: "tester"
+ )
+ }
+
+ /// A copy of `event` with tampered content but the original ID and
+ /// signature — what a forging gateway or mesh peer would produce.
+ private func forge(_ event: NostrEvent) throws -> NostrEvent {
+ let dict: [String: Any] = [
+ "id": event.id,
+ "pubkey": event.pubkey,
+ "created_at": event.created_at,
+ "kind": event.kind,
+ "tags": event.tags,
+ "content": event.content + " (tampered)",
+ "sig": event.sig ?? ""
+ ]
+ return try NostrEvent(from: dict)
+ }
+
+ private func carrierPayload(
+ _ event: NostrEvent,
+ direction: NostrCarrierPacket.Direction = .toGateway,
+ geohash: String = GatewayServiceTests.geohash
+ ) throws -> Data {
+ let packet = try #require(NostrCarrierPacket(direction: direction, geohash: geohash, event: event))
+ return try #require(packet.encode())
+ }
+
+ private func deposit(
+ _ event: NostrEvent,
+ into fixture: Fixture,
+ from depositor: PeerID = PeerID(str: "1122334455667788"),
+ geohash: String = GatewayServiceTests.geohash
+ ) throws {
+ let payload = try carrierPayload(event, direction: .toGateway, geohash: geohash)
+ fixture.service.handleMeshCarrier(payload, from: depositor, directedToUs: true)
+ }
+
+ // MARK: - Uplink verification gates
+
+ @Test("publishes a verified deposit to the geo relays")
+ func verifiedDepositPublished() throws {
+ let fixture = Fixture()
+ let event = try makeEvent()
+ try deposit(event, into: fixture)
+
+ #expect(fixture.published.count == 1)
+ #expect(fixture.published.first?.event.id == event.id)
+ #expect(fixture.published.first?.geohash == Self.geohash)
+ // Viewing the same geohash: the carried message shows on our own timeline.
+ #expect(fixture.injected.map(\.id) == [event.id])
+ }
+
+ @Test("rejects a forged signature")
+ func forgedSignatureRejected() throws {
+ let fixture = Fixture()
+ let forged = try forge(try makeEvent())
+ try deposit(forged, into: fixture)
+
+ #expect(fixture.published.isEmpty)
+ #expect(fixture.injected.isEmpty)
+ }
+
+ @Test("rejects wrong kind, geohash mismatch, and stale events")
+ func structuralGates() throws {
+ let fixture = Fixture()
+
+ // Wrong kind (kind-1 text note instead of kind-20000 ephemeral).
+ let identity = try NostrIdentity.generate()
+ let note = try NostrProtocol.createGeohashTextNote(
+ content: "note",
+ geohash: Self.geohash,
+ senderIdentity: identity
+ )
+ try deposit(note, into: fixture)
+ #expect(fixture.published.isEmpty)
+
+ // Carrier geohash disagreeing with the event's #g tag.
+ let mismatched = try makeEvent(geohash: "9q8yyk")
+ try deposit(mismatched, into: fixture, geohash: Self.geohash)
+ #expect(fixture.published.isEmpty)
+
+ // Stale event (beyond accepted clock skew).
+ let stale = try makeEvent()
+ fixture.advance(GatewayService.Limits.maxEventAgeSeconds + 60)
+ try deposit(stale, into: fixture)
+ #expect(fixture.published.isEmpty)
+ }
+
+ @Test("does nothing while the toggle is off")
+ func disabledGatewayIgnoresDeposits() throws {
+ let fixture = Fixture(enabled: false)
+ try deposit(try makeEvent(), into: fixture)
+ #expect(fixture.published.isEmpty)
+ #expect(fixture.service.queuedUplinks.isEmpty)
+
+ fixture.service.rebroadcastRelayEvent(try makeEvent(), geohash: Self.geohash)
+ #expect(fixture.broadcasts.isEmpty)
+ }
+
+ // MARK: - Uplink quotas and rate limit
+
+ @Test("rate-limits deposits per depositor per minute")
+ func uplinkRateLimit() throws {
+ let fixture = Fixture()
+ let depositor = PeerID(str: "aabbccddeeff0011")
+
+ for _ in 0..
+//
+
+import CryptoKit
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+struct GroupProtocolTests {
+
+ // MARK: - Fixtures
+
+ /// Deterministic member identity: an Ed25519 keypair plus the 64-hex
+ /// fingerprint the roster pins.
+ private struct TestIdentity {
+ let signingKey: Curve25519.Signing.PrivateKey
+ let fingerprint: String
+
+ init(seed: UInt8) {
+ signingKey = Curve25519.Signing.PrivateKey()
+ fingerprint = Data(repeating: seed, count: 32).hexEncodedString()
+ }
+
+ var member: GroupMember {
+ GroupMember(
+ fingerprint: fingerprint,
+ signingKey: signingKey.publicKey.rawRepresentation,
+ nickname: "peer-\(fingerprint.prefix(4))"
+ )
+ }
+
+ func sign(_ data: Data) -> Data? {
+ try? signingKey.signature(for: data)
+ }
+ }
+
+ private let creator = TestIdentity(seed: 0xC1)
+ private let member = TestIdentity(seed: 0xA2)
+ private let outsider = TestIdentity(seed: 0xE3)
+
+ private let groupID = Data((0..<16).map { UInt8($0) })
+ private let key = Data(repeating: 0x42, count: 32)
+
+ private func makeGroup(extraMembers: [GroupMember] = [], epoch: UInt32 = 1) -> BitchatGroup {
+ BitchatGroup(
+ groupID: groupID,
+ name: "trail crew",
+ epoch: epoch,
+ members: [creator.member, member.member] + extraMembers,
+ creatorFingerprint: creator.fingerprint
+ )
+ }
+
+ // MARK: - State payload (invite / key update)
+
+ @Test func statePayloadRoundTripAndSignatureVerify() throws {
+ let group = makeGroup()
+ let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
+ let encoded = try #require(payload.encode())
+
+ let decoded = try #require(GroupStatePayload.decode(encoded))
+ #expect(decoded == payload)
+ #expect(decoded.groupID == groupID)
+ #expect(decoded.name == "trail crew")
+ #expect(decoded.key == key)
+ #expect(decoded.epoch == 1)
+ #expect(decoded.members == group.members)
+ #expect(decoded.creatorFingerprint == creator.fingerprint)
+ #expect(decoded.verifyCreatorSignature())
+ #expect(decoded.asGroup == group)
+ }
+
+ @Test func forgedCreatorSignatureIsRejected() throws {
+ let group = makeGroup()
+ // Signed by a member who is in the roster but is NOT the creator.
+ let forged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: member.sign))
+ #expect(!forged.verifyCreatorSignature())
+
+ // An outsider signing is equally rejected.
+ let outsiderForged = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: outsider.sign))
+ #expect(!outsiderForged.verifyCreatorSignature())
+ }
+
+ @Test func tamperedStateFailsSignature() throws {
+ let group = makeGroup()
+ let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
+
+ // Bumping the epoch invalidates the signature.
+ let epochTampered = GroupStatePayload(
+ groupID: payload.groupID,
+ name: payload.name,
+ key: payload.key,
+ epoch: payload.epoch + 1,
+ members: payload.members,
+ creatorFingerprint: payload.creatorFingerprint,
+ signature: payload.signature
+ )
+ #expect(!epochTampered.verifyCreatorSignature())
+
+ // So does swapping the key.
+ let keyTampered = GroupStatePayload(
+ groupID: payload.groupID,
+ name: payload.name,
+ key: Data(repeating: 0x99, count: 32),
+ epoch: payload.epoch,
+ members: payload.members,
+ creatorFingerprint: payload.creatorFingerprint,
+ signature: payload.signature
+ )
+ #expect(!keyTampered.verifyCreatorSignature())
+
+ // And so does adding a member to the roster.
+ let rosterTampered = GroupStatePayload(
+ groupID: payload.groupID,
+ name: payload.name,
+ key: payload.key,
+ epoch: payload.epoch,
+ members: payload.members + [outsider.member],
+ creatorFingerprint: payload.creatorFingerprint,
+ signature: payload.signature
+ )
+ #expect(!rosterTampered.verifyCreatorSignature())
+ }
+
+ @Test func creatorMissingFromRosterIsRejected() throws {
+ // State claiming a creator whose fingerprint is not in the roster has
+ // no key to verify against and must fail closed.
+ let group = BitchatGroup(
+ groupID: groupID,
+ name: "orphan",
+ epoch: 1,
+ members: [member.member],
+ creatorFingerprint: creator.fingerprint
+ )
+ guard let rosterBlob = GroupRosterCoding.encode(group.members) else {
+ Issue.record("roster should encode")
+ return
+ }
+ let content = GroupStatePayload.signingContent(groupID: groupID, epoch: 1, key: key, rosterBlob: rosterBlob, name: group.name)
+ let payload = GroupStatePayload(
+ groupID: groupID,
+ name: group.name,
+ key: key,
+ epoch: 1,
+ members: group.members,
+ creatorFingerprint: creator.fingerprint,
+ signature: creator.sign(content) ?? Data()
+ )
+ #expect(!payload.verifyCreatorSignature())
+ }
+
+ @Test func rosterCapIsEnforcedOnTheWire() {
+ // 17 members cannot be encoded (hard cap is 16)…
+ let seventeen = (0..<17).map { TestIdentity(seed: UInt8($0 + 1)).member }
+ #expect(GroupRosterCoding.encode(seventeen) == nil)
+
+ // …and a hand-built blob claiming 17 members fails to decode.
+ let sixteen = (0..<16).map { TestIdentity(seed: UInt8($0 + 1)).member }
+ guard var blob = GroupRosterCoding.encode(sixteen) else {
+ Issue.record("16-member roster should encode")
+ return
+ }
+ #expect(GroupRosterCoding.decode(blob)?.count == 16)
+ blob[blob.startIndex] = 17
+ #expect(GroupRosterCoding.decode(blob) == nil)
+ }
+
+ // MARK: - Message seal / open
+
+ @Test func messageRoundTrip() throws {
+ let group = makeGroup()
+ let timestampMs: UInt64 = 1_750_000_000_000
+ let sealed = try GroupCrypto.sealMessage(
+ content: "summit at noon",
+ messageID: "msg-1",
+ senderNickname: "alice",
+ senderSigningKey: member.member.signingKey,
+ timestampMs: timestampMs,
+ groupID: groupID,
+ epoch: group.epoch,
+ key: key,
+ sign: member.sign
+ )
+
+ let envelope = try #require(GroupMessageEnvelope.decode(sealed))
+ #expect(envelope.groupID == groupID)
+ #expect(envelope.epoch == group.epoch)
+
+ let plaintext = try GroupCrypto.openMessage(envelope, key: key)
+ #expect(plaintext.messageID == "msg-1")
+ #expect(plaintext.content == "summit at noon")
+ #expect(plaintext.senderNickname == "alice")
+ #expect(plaintext.timestampMs == timestampMs)
+ #expect(plaintext.senderSigningKey == member.member.signingKey)
+
+ // The roster resolves the sender; an outsider's key would not.
+ #expect(group.member(withSigningKey: plaintext.senderSigningKey) != nil)
+ #expect(group.member(withSigningKey: outsider.member.signingKey) == nil)
+ }
+
+ @Test func wrongKeyFailsToDecrypt() throws {
+ let sealed = try GroupCrypto.sealMessage(
+ content: "hi",
+ messageID: "msg-2",
+ senderNickname: "alice",
+ senderSigningKey: member.member.signingKey,
+ timestampMs: 1,
+ groupID: groupID,
+ epoch: 1,
+ key: key,
+ sign: member.sign
+ )
+ let envelope = try #require(GroupMessageEnvelope.decode(sealed))
+ #expect(throws: GroupCryptoError.decryptionFailed) {
+ _ = try GroupCrypto.openMessage(envelope, key: Data(repeating: 0x7F, count: 32))
+ }
+ }
+
+ @Test func epochIsBoundIntoTheCiphertext() throws {
+ // Re-labeling an epoch-1 envelope as epoch 2 must break the AEAD:
+ // a rotated-out member cannot replay old ciphertext into a new epoch.
+ let sealed = try GroupCrypto.sealMessage(
+ content: "hi",
+ messageID: "msg-3",
+ senderNickname: "alice",
+ senderSigningKey: member.member.signingKey,
+ timestampMs: 1,
+ groupID: groupID,
+ epoch: 1,
+ key: key,
+ sign: member.sign
+ )
+ let envelope = try #require(GroupMessageEnvelope.decode(sealed))
+ let relabeled = GroupMessageEnvelope(
+ groupID: envelope.groupID,
+ epoch: 2,
+ nonce: envelope.nonce,
+ ciphertext: envelope.ciphertext
+ )
+ #expect(throws: GroupCryptoError.decryptionFailed) {
+ _ = try GroupCrypto.openMessage(relabeled, key: key)
+ }
+ }
+
+ @Test func badSenderSignatureIsRejected() throws {
+ // A key-holder who signs with a key other than the one they claim
+ // (or garbage) is dropped even though decryption succeeds.
+ let sealed = try GroupCrypto.sealMessage(
+ content: "spoof",
+ messageID: "msg-4",
+ senderNickname: "mallory",
+ senderSigningKey: member.member.signingKey, // claims member's key…
+ timestampMs: 1,
+ groupID: groupID,
+ epoch: 1,
+ key: key,
+ sign: outsider.sign // …but signs with the outsider's
+ )
+ let envelope = try #require(GroupMessageEnvelope.decode(sealed))
+ #expect(throws: GroupCryptoError.badSenderSignature) {
+ _ = try GroupCrypto.openMessage(envelope, key: key)
+ }
+ }
+
+ @Test func tamperedCiphertextFailsToOpen() throws {
+ let sealed = try GroupCrypto.sealMessage(
+ content: "hi",
+ messageID: "msg-5",
+ senderNickname: "alice",
+ senderSigningKey: member.member.signingKey,
+ timestampMs: 1,
+ groupID: groupID,
+ epoch: 1,
+ key: key,
+ sign: member.sign
+ )
+ let envelope = try #require(GroupMessageEnvelope.decode(sealed))
+ var flipped = envelope.ciphertext
+ flipped[flipped.startIndex] ^= 0x01
+ let tampered = GroupMessageEnvelope(
+ groupID: envelope.groupID,
+ epoch: envelope.epoch,
+ nonce: envelope.nonce,
+ ciphertext: flipped
+ )
+ #expect(throws: GroupCryptoError.decryptionFailed) {
+ _ = try GroupCrypto.openMessage(tampered, key: key)
+ }
+ }
+
+ @Test func malformedEnvelopesAreRejected() {
+ #expect(GroupMessageEnvelope.decode(Data()) == nil)
+ #expect(GroupMessageEnvelope.decode(Data([0x01, 0x00])) == nil)
+ #expect(GroupStatePayload.decode(Data([0xFF, 0x00, 0x01])) == nil)
+ }
+
+ // MARK: - Oversize / UTF-8 safety (Codex findings)
+
+ @Test func oversizeMessageContentFailsToSealInsteadOfTruncating() {
+ // A content whose UTF-8 exceeds the 16-bit TLV length must fail to
+ // seal (surfacing send_failed) rather than silently truncate into a
+ // ciphertext recipients would drop.
+ let oversize = String(repeating: "a", count: 70_000)
+ #expect(throws: (any Error).self) {
+ _ = try GroupCrypto.sealMessage(
+ content: oversize,
+ messageID: "big",
+ senderNickname: "alice",
+ senderSigningKey: member.member.signingKey,
+ timestampMs: 1,
+ groupID: groupID,
+ epoch: 1,
+ key: key,
+ sign: member.sign
+ )
+ }
+ }
+
+ @Test func multiByteNicknameTruncatesOnScalarBoundary() throws {
+ // 40 euro signs = 120 UTF-8 bytes; a raw 64-byte prefix would split
+ // the 21st scalar and make the roster undecodable. Truncation must
+ // land on a Character boundary so the blob round-trips.
+ let euros = String(repeating: "€", count: 40)
+ let wide = GroupMember(
+ fingerprint: creator.fingerprint,
+ signingKey: creator.member.signingKey,
+ nickname: euros
+ )
+ let blob = try #require(GroupRosterCoding.encode([wide]))
+ let decoded = try #require(GroupRosterCoding.decode(blob))
+ #expect(decoded.count == 1)
+ #expect(Data(decoded[0].nickname.utf8).count <= 64)
+ #expect(decoded[0].nickname.allSatisfy { $0 == "€" })
+ #expect(!decoded[0].nickname.isEmpty)
+ }
+
+ // MARK: - Signable-bytes forward-proofing
+
+ @Test func creatorSignatureCoversName() throws {
+ let group = makeGroup()
+ let payload = try #require(GroupStatePayload.makeSigned(group: group, key: key, sign: creator.sign))
+ #expect(payload.verifyCreatorSignature())
+
+ // Swapping only the display name must invalidate the creator signature.
+ let renamed = GroupStatePayload(
+ groupID: payload.groupID,
+ name: "totally different name",
+ key: payload.key,
+ epoch: payload.epoch,
+ members: payload.members,
+ creatorFingerprint: payload.creatorFingerprint,
+ signature: payload.signature
+ )
+ #expect(!renamed.verifyCreatorSignature())
+ }
+
+ @Test func messageSignatureCoversEpoch() {
+ // The signed bytes differ by epoch, so a signature captured at one
+ // epoch cannot verify when re-sealed under a later epoch key.
+ let atEpoch1 = GroupCrypto.messageSigningContent(
+ groupID: groupID, epoch: 1, messageID: "m", timestampMs: 1, content: "x"
+ )
+ let atEpoch2 = GroupCrypto.messageSigningContent(
+ groupID: groupID, epoch: 2, messageID: "m", timestampMs: 1, content: "x"
+ )
+ #expect(atEpoch1 != atEpoch2)
+ }
+}
diff --git a/bitchatTests/Services/GroupStoreTests.swift b/bitchatTests/Services/GroupStoreTests.swift
new file mode 100644
index 00000000..adf04806
--- /dev/null
+++ b/bitchatTests/Services/GroupStoreTests.swift
@@ -0,0 +1,170 @@
+//
+// GroupStoreTests.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+@MainActor
+struct GroupStoreTests {
+
+ private func makeMember(seed: UInt8, nickname: String = "peer") -> GroupMember {
+ GroupMember(
+ fingerprint: Data(repeating: seed, count: 32).hexEncodedString(),
+ signingKey: Data(repeating: seed &+ 1, count: 32),
+ nickname: nickname
+ )
+ }
+
+ private func tempFileURL() -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("group-store-tests-\(UUID().uuidString)", isDirectory: true)
+ .appendingPathComponent("groups.json")
+ }
+
+ // MARK: - Create / read
+
+ @Test func createGroupStoresMetadataAndKey() throws {
+ let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
+ let creator = makeMember(seed: 0xC1, nickname: "me")
+
+ let group = try #require(store.createGroup(named: "ops", creator: creator))
+ #expect(group.groupID.count == BitchatGroup.groupIDLength)
+ #expect(group.epoch == 1)
+ #expect(group.members == [creator])
+ #expect(group.creatorFingerprint == creator.fingerprint)
+
+ #expect(store.group(withID: group.groupID) == group)
+ #expect(store.group(for: group.peerID) == group)
+ let key = try #require(store.key(forGroupID: group.groupID))
+ #expect(key.count == BitchatGroup.keyLength)
+ #expect(group.peerID.isGroup)
+ #expect(group.peerID.groupIDData == group.groupID)
+ }
+
+ // MARK: - Roster cap
+
+ @Test func rosterCapIsEnforced() throws {
+ let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
+ let creator = makeMember(seed: 0xC1)
+ let group = try #require(store.createGroup(named: "big", creator: creator))
+
+ // Filling to the cap works…
+ let fifteen = (1...15).map { makeMember(seed: UInt8($0)) }
+ #expect(store.updateRoster(groupID: group.groupID, members: [creator] + fifteen) != nil)
+ #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers)
+
+ // …one more is rejected.
+ let overflow = [creator] + fifteen + [makeMember(seed: 0x99)]
+ #expect(store.updateRoster(groupID: group.groupID, members: overflow) == nil)
+ #expect(store.group(withID: group.groupID)?.members.count == BitchatGroup.maxMembers)
+
+ // Direct upsert past the cap is rejected too.
+ var oversized = group
+ oversized.members = overflow
+ #expect(!store.upsert(oversized, key: Data(repeating: 1, count: 32)))
+ }
+
+ @Test func rosterMustRetainCreator() throws {
+ let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
+ let creator = makeMember(seed: 0xC1)
+ let other = makeMember(seed: 0xA1)
+ let group = try #require(store.createGroup(named: "crew", creator: creator))
+
+ #expect(store.updateRoster(groupID: group.groupID, members: [other]) == nil)
+ #expect(store.group(withID: group.groupID)?.members == [creator])
+ }
+
+ // MARK: - Rotation
+
+ @Test func rotateKeyBumpsEpochAndReplacesKey() throws {
+ let store = GroupStore(keychain: MockKeychain(), persistsToDisk: false)
+ let creator = makeMember(seed: 0xC1)
+ let removed = makeMember(seed: 0xA1)
+ let group = try #require(store.createGroup(named: "crew", creator: creator))
+ #expect(store.updateRoster(groupID: group.groupID, members: [creator, removed]) != nil)
+ let oldKey = try #require(store.key(forGroupID: group.groupID))
+
+ let rotation = try #require(store.rotateKey(groupID: group.groupID, members: [creator]))
+ #expect(rotation.group.epoch == 2)
+ #expect(rotation.group.members == [creator])
+ #expect(rotation.key != oldKey)
+ #expect(store.key(forGroupID: group.groupID) == rotation.key)
+ #expect(store.group(withID: group.groupID)?.epoch == 2)
+ }
+
+ // MARK: - Persistence
+
+ @Test func persistsAcrossInstances() throws {
+ let keychain = MockKeychain()
+ let fileURL = tempFileURL()
+ defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
+
+ let creator = makeMember(seed: 0xC1, nickname: "me")
+ let group: BitchatGroup
+ do {
+ let store = GroupStore(keychain: keychain, fileURL: fileURL)
+ group = try #require(store.createGroup(named: "hike", creator: creator))
+ }
+
+ let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
+ #expect(reloaded.groups == [group])
+ #expect(reloaded.key(forGroupID: group.groupID) != nil)
+ }
+
+ @Test func groupsWithoutKeysAreDroppedOnLoad() throws {
+ let keychain = MockKeychain()
+ let fileURL = tempFileURL()
+ defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
+
+ let group: BitchatGroup
+ do {
+ let store = GroupStore(keychain: keychain, fileURL: fileURL)
+ group = try #require(store.createGroup(named: "stale", creator: makeMember(seed: 0xC1)))
+ }
+ // Simulate a keychain wipe without the metadata file being removed.
+ _ = keychain.deleteAllKeychainData()
+
+ let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
+ #expect(reloaded.groups.isEmpty)
+ #expect(reloaded.group(withID: group.groupID) == nil)
+ }
+
+ // MARK: - Panic wipe
+
+ @Test func wipeRemovesMetadataAndKeys() throws {
+ let keychain = MockKeychain()
+ let fileURL = tempFileURL()
+ defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
+
+ let store = GroupStore(keychain: keychain, fileURL: fileURL)
+ let group = try #require(store.createGroup(named: "gone", creator: makeMember(seed: 0xC1)))
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+
+ store.wipe()
+
+ #expect(store.groups.isEmpty)
+ #expect(store.key(forGroupID: group.groupID) == nil)
+ #expect(!FileManager.default.fileExists(atPath: fileURL.path))
+
+ // A fresh instance sees nothing either.
+ let reloaded = GroupStore(keychain: keychain, fileURL: fileURL)
+ #expect(reloaded.groups.isEmpty)
+ }
+
+ @Test func removeGroupDeletesItsKey() throws {
+ let keychain = MockKeychain()
+ let store = GroupStore(keychain: keychain, persistsToDisk: false)
+ let group = try #require(store.createGroup(named: "bye", creator: makeMember(seed: 0xC1)))
+
+ store.removeGroup(withID: group.groupID)
+ #expect(store.groups.isEmpty)
+ #expect(store.key(forGroupID: group.groupID) == nil)
+ }
+}
diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift
new file mode 100644
index 00000000..b565840d
--- /dev/null
+++ b/bitchatTests/Services/MeshDiagnosticsTests.swift
@@ -0,0 +1,300 @@
+//
+// MeshDiagnosticsTests.swift
+// bitchatTests
+//
+// Tests for /ping and /trace command handling and the topology snapshot
+// types backing the mesh topology map.
+// This is free and unencumbered software released into the public domain.
+//
+
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+@Suite(.serialized)
+struct MeshDiagnosticsTests {
+
+ // MARK: - Helpers
+
+ @MainActor
+ private func makeProcessor(
+ context: DiagnosticsMockContext,
+ transport: MockTransport
+ ) -> CommandProcessor {
+ CommandProcessor(
+ contextProvider: context,
+ meshService: transport,
+ identityManager: MockIdentityManager(MockKeychain())
+ )
+ }
+
+ /// Waits for the async ping completion (MainActor hop) to land.
+ @MainActor
+ private func waitForCommandOutput(_ context: DiagnosticsMockContext) async {
+ for _ in 0..<100 {
+ if !context.commandOutputs.isEmpty { return }
+ await Task.yield()
+ try? await Task.sleep(nanoseconds: 5_000_000)
+ }
+ }
+
+ // MARK: - /ping
+
+ @MainActor
+ @Test func pingWithoutArgumentShowsUsage() {
+ let context = DiagnosticsMockContext()
+ let processor = makeProcessor(context: context, transport: MockTransport())
+ let result = processor.process("/ping")
+ switch result {
+ case .error(let message):
+ #expect(message == "usage: /ping ")
+ default:
+ Issue.record("Expected usage error")
+ }
+ }
+
+ @MainActor
+ @Test func pingUnknownPeerFails() {
+ let context = DiagnosticsMockContext()
+ let processor = makeProcessor(context: context, transport: MockTransport())
+ let result = processor.process("/ping @ghost")
+ switch result {
+ case .error(let message):
+ #expect(message == "cannot ping ghost: not found on mesh")
+ default:
+ Issue.record("Expected error result")
+ }
+ }
+
+ @MainActor
+ @Test func pingGeoDMPeerIsRejected() {
+ let context = DiagnosticsMockContext()
+ context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899")
+ let processor = makeProcessor(context: context, transport: MockTransport())
+ let result = processor.process("/ping @alice")
+ switch result {
+ case .error(let message):
+ #expect(message == "cannot ping alice: not found on mesh")
+ default:
+ Issue.record("Expected error for geo peer")
+ }
+ }
+
+ @MainActor
+ @Test func pingSuccessReportsRttAndHops() async {
+ let context = DiagnosticsMockContext()
+ let peerID = PeerID(str: "abcd1234abcd1234")
+ context.nicknameToPeerID["alice"] = peerID
+ let transport = MockTransport()
+ transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
+ let processor = makeProcessor(context: context, transport: transport)
+
+ let result = processor.process("/ping @alice")
+ switch result {
+ case .success(let message):
+ #expect(message == "pinging alice…")
+ default:
+ Issue.record("Expected immediate 'pinging' feedback")
+ }
+ #expect(transport.sentMeshPings == [peerID])
+
+ await waitForCommandOutput(context)
+ #expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"])
+ }
+
+ @MainActor
+ @Test func pingDirectPeerReportsSingleHop() async {
+ let context = DiagnosticsMockContext()
+ context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
+ let transport = MockTransport()
+ transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1)
+ let processor = makeProcessor(context: context, transport: transport)
+
+ _ = processor.process("/ping alice")
+ await waitForCommandOutput(context)
+ #expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"])
+ }
+
+ @MainActor
+ @Test func pingTimeoutReportsNoReply() async {
+ let context = DiagnosticsMockContext()
+ context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
+ let transport = MockTransport()
+ transport.meshPingResult = nil
+ let processor = makeProcessor(context: context, transport: transport)
+
+ _ = processor.process("/ping @alice")
+ await waitForCommandOutput(context)
+ #expect(context.commandOutputs == ["no reply from alice"])
+ }
+
+ @MainActor
+ @Test func pingOutputRoutesToConversationWhereCommandWasIssued() async {
+ let context = DiagnosticsMockContext()
+ let alice = PeerID(str: "abcd1234abcd1234")
+ let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
+ context.nicknameToPeerID["alice"] = alice
+ let transport = MockTransport()
+ transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
+ let processor = makeProcessor(context: context, transport: transport)
+
+ // Issue the ping from bob's DM, then switch chats before the async
+ // result lands. The output must follow the origin conversation, not
+ // whatever is selected at callback time.
+ context.selectedPrivateChatPeer = bob
+ _ = processor.process("/ping @alice")
+ context.selectedPrivateChatPeer = nil
+
+ await waitForCommandOutput(context)
+ #expect(context.commandOutputDestinations == [.privateChat(bob)])
+ }
+
+ @MainActor
+ @Test func pingIssuedFromPublicTimelineRoutesToMeshTimeline() async {
+ let context = DiagnosticsMockContext()
+ let alice = PeerID(str: "abcd1234abcd1234")
+ context.nicknameToPeerID["alice"] = alice
+ let transport = MockTransport()
+ transport.meshPingResult = MeshPingResult(rttMs: 7, hops: 1)
+ let processor = makeProcessor(context: context, transport: transport)
+
+ _ = processor.process("/ping @alice")
+ // Opening a DM afterwards must not swallow the public-timeline result.
+ context.selectedPrivateChatPeer = alice
+
+ await waitForCommandOutput(context)
+ #expect(context.commandOutputDestinations == [.meshTimeline])
+ }
+
+ // MARK: - /trace
+
+ @MainActor
+ @Test func traceDirectPeerShowsOneHop() {
+ let context = DiagnosticsMockContext()
+ let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
+ context.nicknameToPeerID["bob"] = bob
+ let transport = MockTransport()
+ transport.meshPaths[bob] = []
+ let processor = makeProcessor(context: context, transport: transport)
+
+ let result = processor.process("/trace @bob")
+ switch result {
+ case .success(let message):
+ #expect(message == "estimated path: you → bob (1 hop)")
+ default:
+ Issue.record("Expected success result")
+ }
+ }
+
+ @MainActor
+ @Test func traceMultiHopUsesNicknamesWithShortIDFallback() {
+ let context = DiagnosticsMockContext()
+ let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
+ let alice = PeerID(str: "a11cea11cea11cea")
+ let unknown = PeerID(str: "dead00beef001234")
+ context.nicknameToPeerID["bob"] = bob
+ let transport = MockTransport()
+ transport.peerNicknames = [alice: "alice"]
+ transport.meshPaths[bob] = [alice, unknown]
+ let processor = makeProcessor(context: context, transport: transport)
+
+ let result = processor.process("/trace bob")
+ switch result {
+ case .success(let message):
+ #expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)")
+ default:
+ Issue.record("Expected success result")
+ }
+ }
+
+ @MainActor
+ @Test func traceWithoutPathReportsNoKnownPath() {
+ let context = DiagnosticsMockContext()
+ context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0")
+ let processor = makeProcessor(context: context, transport: MockTransport())
+
+ let result = processor.process("/trace @bob")
+ switch result {
+ case .success(let message):
+ #expect(message == "no known path to bob")
+ default:
+ Issue.record("Expected success result")
+ }
+ }
+
+ // MARK: - Topology snapshot types
+
+ @Test func topologyEdgeNormalizesEndpointOrder() {
+ let a = PeerID(str: "aaaa000000000000")
+ let b = PeerID(str: "bbbb000000000000")
+ #expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a))
+ #expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1)
+ }
+
+ @Test func topologyLayoutPlacesSelfInCenter() {
+ let nodes: [MeshTopologyDisplayModel.Node] = [
+ .init(id: "self", label: "me", isSelf: true),
+ .init(id: "a", label: "alice", isSelf: false),
+ .init(id: "b", label: "bob", isSelf: false)
+ ]
+ let size = CGSize(width: 200, height: 200)
+ let positions = MeshTopologyView.layout(nodes: nodes, in: size)
+
+ #expect(positions["self"] == CGPoint(x: 100, y: 100))
+ #expect(positions.count == 3)
+ // Ring nodes sit on the same radius around the center.
+ let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100)
+ let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100)
+ #expect(abs(radiusA - radiusB) < 0.001)
+ #expect(radiusA > 0)
+ }
+}
+
+/// Minimal CommandContextProvider for diagnostics tests; records deferred
+/// command output so async /ping results can be asserted.
+@MainActor
+private final class DiagnosticsMockContext: CommandContextProvider {
+ var nickname: String = "tester"
+ var activeChannel: ChannelID = .mesh
+ var selectedPrivateChatPeer: PeerID?
+ var blockedUsers: Set = []
+ let idBridge = NostrIdentityBridge(keychain: MockKeychain())
+
+ var nicknameToPeerID: [String: PeerID] = [:]
+ private(set) var commandOutputs: [String] = []
+ private(set) var commandOutputDestinations: [CommandOutputDestination] = []
+
+ func getPeerIDForNickname(_ nickname: String) -> PeerID? {
+ nicknameToPeerID[nickname]
+ }
+
+ func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
+ func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
+ func startPrivateChat(with peerID: PeerID) {}
+ func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
+ func clearCurrentPublicTimeline() {}
+ func clearPrivateChat(_ peerID: PeerID) {}
+ func sendPublicRaw(_ content: String) {}
+ func sendPublicMessage(_ content: String) {}
+ func groupCreate(named name: String) -> CommandResult { .handled }
+ func groupInvite(nickname: String) -> CommandResult { .handled }
+ func groupRemove(nickname: String) -> CommandResult { .handled }
+ func groupLeave() -> CommandResult { .handled }
+ func groupList() -> CommandResult { .handled }
+ func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
+ func addPublicSystemMessage(_ content: String) {}
+ func toggleFavorite(peerID: PeerID) {}
+
+ func currentCommandDestination() -> CommandOutputDestination {
+ if let peerID = selectedPrivateChatPeer {
+ return .privateChat(peerID)
+ }
+ return .meshTimeline
+ }
+
+ func addCommandOutput(_ content: String, to destination: CommandOutputDestination) {
+ commandOutputs.append(content)
+ commandOutputDestinations.append(destination)
+ }
+}
diff --git a/bitchatTests/Services/MeshTopologyTrackerTests.swift b/bitchatTests/Services/MeshTopologyTrackerTests.swift
index 1ae0f5f5..fa5d2a4d 100644
--- a/bitchatTests/Services/MeshTopologyTrackerTests.swift
+++ b/bitchatTests/Services/MeshTopologyTrackerTests.swift
@@ -94,10 +94,132 @@ struct MeshTopologyTrackerTests {
tracker.updateNeighbors(for: a, neighbors: [b])
tracker.updateNeighbors(for: b, neighbors: [a])
-
+
// When start == end, route should be empty (no intermediate hops needed)
let route = try #require(tracker.computeRoute(from: a, to: a))
#expect(route == [])
}
+ @Test func noPathReturnsNil() throws {
+ let tracker = MeshTopologyTracker()
+ let a = try hex("0101010101010101")
+ let b = try hex("0202020202020202")
+ let c = try hex("0303030303030303")
+ let d = try hex("0404040404040404")
+
+ // Two disconnected islands: A-B and C-D
+ tracker.updateNeighbors(for: a, neighbors: [b])
+ tracker.updateNeighbors(for: b, neighbors: [a])
+ tracker.updateNeighbors(for: c, neighbors: [d])
+ tracker.updateNeighbors(for: d, neighbors: [c])
+
+ #expect(tracker.computeRoute(from: a, to: d) == nil)
+ }
+
+ /// Build a confirmed line topology n0 - n1 - ... - n(count-1).
+ private func makeLine(_ tracker: MeshTopologyTracker, count: Int) throws -> [Data] {
+ let nodes = try (0.. 0 { neighbors.append(nodes[i - 1]) }
+ if i < count - 1 { neighbors.append(nodes[i + 1]) }
+ tracker.updateNeighbors(for: nodes[i], neighbors: neighbors)
+ }
+ return nodes
+ }
+
+ @Test func maxHopsCapsIntermediateHopCount() throws {
+ let tracker = MeshTopologyTracker()
+ // 7 nodes: source + 5 intermediates + target
+ let nodes = try makeLine(tracker, count: 7)
+
+ // 5 intermediates exceed a 4-hop cap
+ #expect(tracker.computeRoute(from: nodes[0], to: nodes[6], maxHops: 4) == nil)
+ // 4 intermediates fit exactly
+ let route = try #require(tracker.computeRoute(from: nodes[0], to: nodes[5], maxHops: 4))
+ #expect(route == Array(nodes[1...4]))
+ }
+
+ @Test func staleNeighborBlocksRoute() throws {
+ let tracker = MeshTopologyTracker()
+ let a = try hex("0101010101010101")
+ let b = try hex("0202020202020202")
+ let c = try hex("0303030303030303")
+
+ let staleDate = Date().addingTimeInterval(-120) // past 60s freshness
+ tracker.updateNeighbors(for: a, neighbors: [b])
+ tracker.updateNeighbors(for: b, neighbors: [a, c], at: staleDate)
+ tracker.updateNeighbors(for: c, neighbors: [b])
+
+ #expect(tracker.computeRoute(from: a, to: c) == nil)
+
+ // Refreshing B restores the route.
+ tracker.updateNeighbors(for: b, neighbors: [a, c])
+ let route = try #require(tracker.computeRoute(from: a, to: c))
+ #expect(route == [b])
+ }
+
+ @Test func versionGateBlocksV1AndUnknownHops() throws {
+ let tracker = MeshTopologyTracker()
+ let a = try hex("0101010101010101")
+ let b = try hex("0202020202020202")
+ let c = try hex("0303030303030303")
+
+ tracker.updateNeighbors(for: a, neighbors: [b])
+ tracker.updateNeighbors(for: b, neighbors: [a, c])
+ tracker.updateNeighbors(for: c, neighbors: [b])
+
+ // Without the gate the route exists.
+ #expect(tracker.computeRoute(from: a, to: c) == [b])
+ // Version-unknown hops are assumed v1-only and block gated routes.
+ #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
+
+ // A v1 observation does not unlock the gate.
+ tracker.recordObservedVersion(1, for: b)
+ tracker.recordObservedVersion(2, for: c)
+ #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
+
+ // Once the hop is observed speaking v2 the route opens.
+ tracker.recordObservedVersion(2, for: b)
+ let route = try #require(tracker.computeRoute(from: a, to: c, requiringVersion: 2))
+ #expect(route == [b])
+ }
+
+ @Test func versionGateRequiresV2Target() throws {
+ let tracker = MeshTopologyTracker()
+ let a = try hex("0101010101010101")
+ let b = try hex("0202020202020202")
+ let c = try hex("0303030303030303")
+
+ tracker.updateNeighbors(for: a, neighbors: [b])
+ tracker.updateNeighbors(for: b, neighbors: [a, c])
+ tracker.updateNeighbors(for: c, neighbors: [b])
+ tracker.recordObservedVersion(2, for: b)
+
+ // The recipient must decode the v2 frame too.
+ #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
+
+ tracker.recordObservedVersion(2, for: c)
+ #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b])
+ }
+
+ @Test func pruneDropsStaleObservedVersions() throws {
+ let tracker = MeshTopologyTracker()
+ let a = try hex("0101010101010101")
+ let b = try hex("0202020202020202")
+ let c = try hex("0303030303030303")
+
+ tracker.updateNeighbors(for: a, neighbors: [b])
+ tracker.updateNeighbors(for: b, neighbors: [a, c])
+ tracker.updateNeighbors(for: c, neighbors: [b])
+ let old = Date().addingTimeInterval(-120)
+ tracker.recordObservedVersion(2, for: b, at: old)
+ tracker.recordObservedVersion(2, for: c, at: old)
+
+ #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == [b])
+ tracker.prune(olderThan: 60)
+ // Claims are fresh but the version observations aged out.
+ #expect(tracker.computeRoute(from: a, to: c, requiringVersion: 2) == nil)
+ }
+
}
diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift
new file mode 100644
index 00000000..218f6cf0
--- /dev/null
+++ b/bitchatTests/Services/MessageOutboxStoreTests.swift
@@ -0,0 +1,92 @@
+//
+// MessageOutboxStoreTests.swift
+// bitchatTests
+//
+// Tests for the encrypted-at-rest outbox persistence.
+//
+
+import Testing
+import Foundation
+import BitFoundation
+@testable import bitchat
+
+struct MessageOutboxStoreTests {
+
+ private func makeTempURL() -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("outbox-\(UUID().uuidString).sealed")
+ }
+
+ private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage {
+ MessageOutboxStore.QueuedMessage(
+ content: content,
+ nickname: "peer",
+ messageID: id,
+ timestamp: Date(timeIntervalSince1970: 1_750_000_000),
+ sendAttempts: 2,
+ depositedCourierKeys: [Data(repeating: 0xC1, count: 32)]
+ )
+ }
+
+ @Test func roundTripAcrossInstances() {
+ let fileURL = makeTempURL()
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let keychain = MockKeychain()
+ let peerID = PeerID(str: "0000000000000001")
+
+ let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ store.save([peerID: [makeMessage("m1")]])
+
+ // Same keychain (encryption key) reads it back, fields intact.
+ let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
+ #expect(reloaded[peerID]?.count == 1)
+ #expect(reloaded[peerID]?.first?.messageID == "m1")
+ #expect(reloaded[peerID]?.first?.sendAttempts == 2)
+ #expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1)
+ }
+
+ @Test func contentIsNotPlaintextOnDisk() throws {
+ let fileURL = makeTempURL()
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
+ store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]])
+
+ let raw = try Data(contentsOf: fileURL)
+ #expect(!raw.isEmpty)
+ // Sealed bytes must not contain the message plaintext.
+ #expect(raw.range(of: Data("very secret message".utf8)) == nil)
+ }
+
+ @Test func loadWithoutKeyReturnsEmpty() {
+ let fileURL = makeTempURL()
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
+ store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
+
+ // A different keychain (fresh device / wiped key) cannot read the file.
+ let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
+ #expect(other.load().isEmpty)
+ }
+
+ @Test func wipeRemovesFileAndKey() {
+ let fileURL = makeTempURL()
+ let keychain = MockKeychain()
+ let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+
+ store.wipe()
+ #expect(!FileManager.default.fileExists(atPath: fileURL.path))
+ #expect(store.load().isEmpty)
+ }
+
+ @Test func savingEmptyOutboxRemovesFile() {
+ let fileURL = makeTempURL()
+ let keychain = MockKeychain()
+ let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ let peerID = PeerID(str: "0000000000000001")
+ store.save([peerID: [makeMessage("m1")]])
+ store.save([peerID: []])
+ #expect(!FileManager.default.fileExists(atPath: fileURL.path))
+ }
+}
diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift
index 13f020a9..78a9caec 100644
--- a/bitchatTests/Services/MessageRouterTests.swift
+++ b/bitchatTests/Services/MessageRouterTests.swift
@@ -226,6 +226,197 @@ struct MessageRouterTests {
#expect(transport.sentFavoriteNotifications.count == 1)
}
+
+ // MARK: - Courier deposits
+
+ private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot {
+ TransportPeerSnapshot(
+ peerID: peerID,
+ nickname: "peer",
+ isConnected: true,
+ noisePublicKey: key,
+ lastSeen: Date(),
+ isVerified: verified
+ )
+ }
+
+ /// Directory that resolves one offline recipient and treats a fixed key
+ /// set as mutual favorites.
+ private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set = []) -> CourierDirectory {
+ CourierDirectory(
+ noiseKey: { peerID in peerID == recipient ? recipientKey : nil },
+ isTrustedCourier: { favoriteKeys.contains($0) }
+ )
+ }
+
+ @Test @MainActor
+ func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+ let courier = PeerID(str: "00000000000000cc")
+ let courierKey = Data(repeating: 0xCC, count: 32)
+
+ let transport = MockTransport()
+ transport.connectedPeers.insert(courier)
+ transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
+
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1")
+
+ #expect(transport.sentCourierMessages.count == 1)
+ #expect(transport.sentCourierMessages.first?.couriers == [courier])
+ }
+
+ @Test @MainActor
+ func sendPrivate_neverDepositsWithUnverifiedStranger() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let courier = PeerID(str: "00000000000000cc")
+
+ let transport = MockTransport()
+ transport.connectedPeers.insert(courier)
+ transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)])
+
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32))
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2")
+
+ #expect(transport.sentCourierMessages.isEmpty)
+ }
+
+ @Test @MainActor
+ func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+ let favorite = PeerID(str: "00000000000000f0")
+ let favoriteKey = Data(repeating: 0xF0, count: 32)
+ var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)]
+ let transport = MockTransport()
+ transport.connectedPeers.insert(favorite)
+ // Three verified strangers compete for the three courier slots.
+ for byte: UInt8 in [0xC1, 0xC2, 0xC3] {
+ let peer = PeerID(str: String(format: "00000000000000%02x", byte))
+ transport.connectedPeers.insert(peer)
+ snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true))
+ }
+ transport.updatePeerSnapshots(snapshots)
+
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey])
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3")
+
+ let couriers = transport.sentCourierMessages.first?.couriers ?? []
+ #expect(couriers.count == 3)
+ #expect(couriers.contains(favorite))
+ }
+
+ @Test @MainActor
+ func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+ let courier = PeerID(str: "00000000000000cc")
+ let courierKey = Data(repeating: 0xCC, count: 32)
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
+ )
+ // Nobody around at send time: the message just queues.
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1")
+ #expect(transport.sentCourierMessages.isEmpty)
+
+ // A verified courier appears later: the deposit retries.
+ transport.connectedPeers.insert(courier)
+ transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
+ router.courierBecameAvailable(courier)
+ #expect(transport.sentCourierMessages.count == 1)
+ #expect(transport.sentCourierMessages.first?.couriers == [courier])
+
+ // The same courier reconnecting does not receive the same mail twice.
+ router.courierBecameAvailable(courier)
+ #expect(transport.sentCourierMessages.count == 1)
+ }
+
+ @Test @MainActor
+ func courierBecameAvailable_ignoresTheRecipientThemselves() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2")
+
+ // The recipient connecting is a flush, not a courier opportunity.
+ transport.connectedPeers.insert(recipient)
+ transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)])
+ router.courierBecameAvailable(recipient)
+ #expect(transport.sentCourierMessages.isEmpty)
+ }
+
+ // MARK: - Outbox persistence
+
+ @Test @MainActor
+ func queuedMessagesSurviveRouterRestart() async {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let keychain = MockKeychain()
+ let peerID = PeerID(str: "00000000000000dd")
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1")
+ #expect(transport.sentPrivateMessages.isEmpty)
+
+ // "App restart": a fresh router over the same store, peer now around.
+ let transport2 = MockTransport()
+ transport2.reachablePeers.insert(peerID)
+ let router2 = MessageRouter(
+ transports: [transport2],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router2.flushOutbox(for: peerID)
+ #expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"])
+ }
+
+ @Test @MainActor
+ func deliveredMessagesDoNotResurrectAfterRestart() async {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let keychain = MockKeychain()
+ let peerID = PeerID(str: "00000000000000de")
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2")
+ router.markDelivered("p2")
+
+ let transport2 = MockTransport()
+ transport2.reachablePeers.insert(peerID)
+ let router2 = MessageRouter(
+ transports: [transport2],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router2.flushOutbox(for: peerID)
+ #expect(transport2.sentPrivateMessages.isEmpty)
+ }
}
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
diff --git a/bitchatTests/Services/NetworkActivationServiceTests.swift b/bitchatTests/Services/NetworkActivationServiceTests.swift
index 5523ccdc..01d8fdca 100644
--- a/bitchatTests/Services/NetworkActivationServiceTests.swift
+++ b/bitchatTests/Services/NetworkActivationServiceTests.swift
@@ -111,6 +111,7 @@ final class NetworkActivationServiceTests: XCTestCase {
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
permissionProvider: { permissionSubject.value },
mutualFavoritesProvider: { favoritesSubject.value },
+ reachabilityMonitor: AlwaysReachableMonitor(),
torController: torController,
relayController: relayController,
proxyController: proxyController,
diff --git a/bitchatTests/Services/NetworkReachabilityGateTests.swift b/bitchatTests/Services/NetworkReachabilityGateTests.swift
new file mode 100644
index 00000000..420a54db
--- /dev/null
+++ b/bitchatTests/Services/NetworkReachabilityGateTests.swift
@@ -0,0 +1,207 @@
+import Combine
+import XCTest
+@testable import bitchat
+
+/// Covers the reachability-gate decision logic (pure debounce) and the
+/// `NetworkActivationService` wiring that suppresses Tor/relay startup when
+/// there is provably no network.
+@MainActor
+final class NetworkReachabilityGateTests: XCTestCase {
+
+ // MARK: - Pure debounce logic
+
+ func test_debounce_satisfiedStaysReachable() {
+ var d = ReachabilityDebounce(interval: 2.5, initial: true)
+ let t0 = Date()
+ // An interface remains present: no change, no pending.
+ XCTAssertNil(d.observe(reachable: true, at: t0))
+ XCTAssertTrue(d.committed)
+ XCTAssertFalse(d.hasPendingChange)
+ }
+
+ func test_debounce_unsatisfiedSuppressesAfterInterval() {
+ var d = ReachabilityDebounce(interval: 2.5, initial: true)
+ let t0 = Date()
+ // Path drops: not committed immediately (within debounce window).
+ XCTAssertNil(d.observe(reachable: false, at: t0))
+ XCTAssertTrue(d.committed)
+ XCTAssertTrue(d.hasPendingChange)
+ // Still within window.
+ XCTAssertNil(d.flush(at: t0.addingTimeInterval(1.0)))
+ XCTAssertTrue(d.committed)
+ // Past the window: commit unreachable.
+ XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), false)
+ XCTAssertFalse(d.committed)
+ XCTAssertFalse(d.hasPendingChange)
+ }
+
+ func test_debounce_flapIsIgnored() {
+ var d = ReachabilityDebounce(interval: 2.5, initial: true)
+ let t0 = Date()
+ // Drop then recover well within the window — must never commit a change.
+ XCTAssertNil(d.observe(reachable: false, at: t0))
+ XCTAssertTrue(d.hasPendingChange)
+ XCTAssertNil(d.observe(reachable: true, at: t0.addingTimeInterval(0.5)))
+ XCTAssertFalse(d.hasPendingChange, "recovery should cancel the pending drop")
+ // A late flush after the original deadline is a no-op (nothing pending).
+ XCTAssertNil(d.flush(at: t0.addingTimeInterval(3.0)))
+ XCTAssertTrue(d.committed)
+ }
+
+ func test_debounce_recoverAfterOutageCommitsAfterInterval() {
+ var d = ReachabilityDebounce(interval: 2.5, initial: false)
+ let t0 = Date()
+ XCTAssertNil(d.observe(reachable: true, at: t0))
+ XCTAssertTrue(d.hasPendingChange)
+ XCTAssertEqual(d.flush(at: t0.addingTimeInterval(2.5)), true)
+ XCTAssertTrue(d.committed)
+ }
+
+ // MARK: - Service gating
+
+ func test_start_whenUnreachable_suppressesTorAndRelays() {
+ let ctx = makeService(permission: .authorized, reachable: false)
+ ctx.service.start()
+
+ XCTAssertFalse(ctx.service.activationAllowed)
+ XCTAssertFalse(ctx.service.isNetworkReachable)
+ XCTAssertTrue(ctx.reachability.startCalled)
+ XCTAssertEqual(ctx.torController.startIfNeededCallCount, 0)
+ XCTAssertEqual(ctx.torController.autoStartAllowedValues, [false])
+ XCTAssertEqual(ctx.relayController.connectCallCount, 0)
+ XCTAssertEqual(ctx.relayController.disconnectCallCount, 1)
+ }
+
+ func test_start_whenReachable_allowsTorAndRelays() {
+ let ctx = makeService(permission: .authorized, reachable: true)
+ ctx.service.start()
+
+ XCTAssertTrue(ctx.service.activationAllowed)
+ XCTAssertEqual(ctx.torController.startIfNeededCallCount, 1)
+ XCTAssertEqual(ctx.relayController.connectCallCount, 1)
+ }
+
+ func test_reachabilityRecovery_resumesTorAndRelays() async {
+ let ctx = makeService(permission: .authorized, reachable: false)
+ ctx.service.start()
+ XCTAssertFalse(ctx.service.activationAllowed)
+
+ ctx.reachability.set(true)
+ let resumed = await waitUntil { ctx.service.activationAllowed }
+ XCTAssertTrue(resumed)
+ XCTAssertTrue(ctx.service.isNetworkReachable)
+ XCTAssertGreaterThanOrEqual(ctx.torController.startIfNeededCallCount, 1)
+ XCTAssertGreaterThanOrEqual(ctx.relayController.connectCallCount, 1)
+ }
+
+ func test_reachabilityLoss_disconnectsRelaysAndStopsTor() async {
+ let ctx = makeService(permission: .authorized, reachable: true)
+ ctx.service.start()
+ XCTAssertTrue(ctx.service.activationAllowed)
+ let disconnectsBefore = ctx.relayController.disconnectCallCount
+
+ ctx.reachability.set(false)
+ let suppressed = await waitUntil { !ctx.service.activationAllowed }
+ XCTAssertTrue(suppressed)
+ XCTAssertFalse(ctx.service.isNetworkReachable)
+ XCTAssertGreaterThan(ctx.relayController.disconnectCallCount, disconnectsBefore)
+ XCTAssertTrue(ctx.torController.autoStartAllowedValues.contains(false))
+ XCTAssertGreaterThanOrEqual(ctx.torController.shutdownCompletelyCallCount, 1)
+ }
+
+ // MARK: - Harness
+
+ private func makeService(
+ permission: LocationChannelManager.PermissionState,
+ reachable: Bool
+ ) -> Context {
+ let suiteName = "NetworkReachabilityGateTests-\(UUID().uuidString)"
+ let storage = UserDefaults(suiteName: suiteName)!
+ storage.removePersistentDomain(forName: suiteName)
+
+ let permissionSubject = CurrentValueSubject(permission)
+ let favoritesSubject = CurrentValueSubject, Never>([])
+ let reachability = ControllableReachabilityMonitor(initial: reachable)
+ let torController = GateMockTorController()
+ let relayController = GateMockRelayController()
+ let proxyController = GateMockProxyController()
+ let service = NetworkActivationService(
+ storage: storage,
+ locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
+ mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
+ permissionProvider: { permissionSubject.value },
+ mutualFavoritesProvider: { favoritesSubject.value },
+ reachabilityMonitor: reachability,
+ torController: torController,
+ relayController: relayController,
+ proxyController: proxyController,
+ notificationCenter: NotificationCenter()
+ )
+ return Context(
+ service: service,
+ reachability: reachability,
+ torController: torController,
+ relayController: relayController
+ )
+ }
+
+ private func waitUntil(
+ timeout: TimeInterval = 1.0,
+ condition: @escaping @MainActor () -> Bool
+ ) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if condition() { return true }
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+ return condition()
+ }
+}
+
+@MainActor
+private struct Context {
+ let service: NetworkActivationService
+ let reachability: ControllableReachabilityMonitor
+ let torController: GateMockTorController
+ let relayController: GateMockRelayController
+}
+
+@MainActor
+private final class ControllableReachabilityMonitor: NetworkReachabilityMonitoring {
+ private let subject: CurrentValueSubject
+ private(set) var startCalled = false
+
+ init(initial: Bool) {
+ subject = CurrentValueSubject(initial)
+ }
+
+ var isReachable: Bool { subject.value }
+ var reachabilityPublisher: AnyPublisher {
+ subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
+ }
+ func start() { startCalled = true }
+ func set(_ reachable: Bool) { subject.send(reachable) }
+}
+
+@MainActor
+private final class GateMockTorController: NetworkActivationTorControlling {
+ private(set) var autoStartAllowedValues: [Bool] = []
+ private(set) var startIfNeededCallCount = 0
+ private(set) var shutdownCompletelyCallCount = 0
+ func setAutoStartAllowed(_ allowed: Bool) { autoStartAllowedValues.append(allowed) }
+ func startIfNeeded() { startIfNeededCallCount += 1 }
+ func shutdownCompletely() { shutdownCompletelyCallCount += 1 }
+}
+
+@MainActor
+private final class GateMockRelayController: NetworkActivationRelayControlling {
+ private(set) var connectCallCount = 0
+ private(set) var disconnectCallCount = 0
+ func connect() { connectCallCount += 1 }
+ func disconnect() { disconnectCallCount += 1 }
+}
+
+private final class GateMockProxyController: NetworkActivationProxyControlling {
+ private(set) var proxyModes: [Bool] = []
+ func setProxyMode(useTor: Bool) { proxyModes.append(useTor) }
+}
diff --git a/bitchatTests/Services/RelayControllerTests.swift b/bitchatTests/Services/RelayControllerTests.swift
index c0c473cf..7c04fab6 100644
--- a/bitchatTests/Services/RelayControllerTests.swift
+++ b/bitchatTests/Services/RelayControllerTests.swift
@@ -134,6 +134,25 @@ struct RelayControllerTests {
#expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1)
}
+ @Test
+ func requestSync_neverRelaysEvenWithTTLHeadroom() async {
+ let decision = RelayController.decide(
+ ttl: 7,
+ senderIsSelf: false,
+ isEncrypted: false,
+ isDirectedEncrypted: false,
+ isFragment: false,
+ isDirectedFragment: false,
+ isHandshake: false,
+ isAnnounce: false,
+ isRequestSync: true,
+ degree: 3,
+ highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
+ )
+
+ #expect(!decision.shouldRelay)
+ }
+
@Test
func denseGraph_capsTTL() async {
let decision = RelayController.decide(
diff --git a/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift
new file mode 100644
index 00000000..6ab46490
--- /dev/null
+++ b/bitchatTests/Services/SecureIdentityStateManagerVouchTests.swift
@@ -0,0 +1,335 @@
+import Foundation
+import Testing
+
+@testable import bitchat
+
+/// Vouch storage, accept-policy gates, derived trust levels, and persistence
+/// compatibility for `SecureIdentityStateManager`.
+///
+/// Ordering note: mutations use barrier blocks on the manager's concurrent
+/// queue and reads use `queue.sync`, so a read submitted after a mutation
+/// always observes it — no polling needed.
+///
+/// `@MainActor` matches production (the manager's vouch API is driven by the
+/// main-actor `ChatVouchCoordinator`) and keeps the blocking `queue.sync`
+/// reads off the Swift Concurrency cooperative pool. Left nonisolated, Swift
+/// Testing runs these tests in parallel on that pool, and on CI's few-core
+/// runners every pool thread ended up parked in `queue.sync` behind a pending
+/// `queue.async(.barrier)` write that never got a dispatch worker — a
+/// process-wide deadlock (watchdog SIGKILL, exit 137).
+@MainActor
+struct SecureIdentityStateManagerVouchTests {
+ private let voucher = String(repeating: "0a", count: 32)
+ private let vouchee = String(repeating: "0b", count: 32)
+
+ private func makeManager() -> SecureIdentityStateManager {
+ SecureIdentityStateManager(MockKeychain())
+ }
+
+ // MARK: - Accept-policy gates
+
+ @Test
+ func recordVouch_rejectsUnverifiedVoucher() {
+ let manager = makeManager()
+
+ #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
+ #expect(manager.validVouchers(for: vouchee).isEmpty)
+
+ manager.setVerified(fingerprint: voucher, verified: true)
+ #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
+ #expect(manager.validVouchers(for: vouchee).count == 1)
+ }
+
+ @Test
+ func recordVouch_ignoresSelfVouch() {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+
+ #expect(!manager.recordVouch(voucheeFingerprint: voucher, voucherFingerprint: voucher, timestamp: Date()))
+ #expect(manager.validVouchers(for: voucher).isEmpty)
+ }
+
+ @Test
+ func recordVouch_ignoresAlreadyVerifiedVouchee() {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+ manager.setVerified(fingerprint: vouchee, verified: true)
+
+ #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date()))
+ #expect(!manager.isVouched(fingerprint: vouchee))
+ }
+
+ @Test
+ func recordVouch_rejectsStaleAndFarFutureTimestamps() {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+
+ let stale = Date().addingTimeInterval(-31 * 24 * 60 * 60)
+ #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: stale))
+
+ let farFuture = Date().addingTimeInterval(2 * 60 * 60)
+ #expect(!manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: farFuture))
+
+ #expect(manager.validVouchers(for: vouchee).isEmpty)
+ }
+
+ @Test
+ func recordVouch_capsVouchersPerVoucheeKeepingMostRecent() {
+ let manager = makeManager()
+ let base = Date()
+
+ // 9 verified vouchers vouch with strictly increasing timestamps.
+ let vouchers = (0..<9).map { String(format: "%02x", $0 + 0x10) + String(repeating: "00", count: 31) }
+ for (index, voucherFingerprint) in vouchers.enumerated() {
+ manager.setVerified(fingerprint: voucherFingerprint, verified: true)
+ let stored = manager.recordVouch(
+ voucheeFingerprint: vouchee,
+ voucherFingerprint: voucherFingerprint,
+ timestamp: base.addingTimeInterval(TimeInterval(index)),
+ now: base.addingTimeInterval(TimeInterval(index))
+ )
+ #expect(stored)
+ }
+
+ let records = manager.validVouchers(for: vouchee)
+ #expect(records.count == SecureIdentityStateManager.maxVouchersPerVouchee)
+ // The oldest voucher fell off the end.
+ #expect(!records.contains { $0.voucherFingerprint == vouchers[0] })
+ #expect(records.contains { $0.voucherFingerprint == vouchers[8] })
+
+ // An attestation older than everything retained is not stored.
+ let older = String(repeating: "0c", count: 32)
+ manager.setVerified(fingerprint: older, verified: true)
+ #expect(!manager.recordVouch(
+ voucheeFingerprint: vouchee,
+ voucherFingerprint: older,
+ timestamp: base.addingTimeInterval(-1),
+ now: base
+ ))
+
+ // A repeat vouch from a retained voucher refreshes, not duplicates.
+ #expect(manager.recordVouch(
+ voucheeFingerprint: vouchee,
+ voucherFingerprint: vouchers[8],
+ timestamp: base.addingTimeInterval(100),
+ now: base.addingTimeInterval(100)
+ ))
+ #expect(manager.validVouchers(for: vouchee).count == SecureIdentityStateManager.maxVouchersPerVouchee)
+ }
+
+ // MARK: - Derived trust & invalidation
+
+ @Test
+ func unverifyingVoucher_invalidatesTheirVouchesWithoutDeletingThem() {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+ manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
+ #expect(manager.isVouched(fingerprint: vouchee))
+
+ // Removing my verification of the voucher retires their vouches…
+ manager.setVerified(fingerprint: voucher, verified: false)
+ #expect(!manager.isVouched(fingerprint: vouchee))
+ #expect(manager.validVouchers(for: vouchee).isEmpty)
+
+ // …but the records survive: re-verifying the voucher restores them
+ // (recompute on read, no cascade delete).
+ manager.setVerified(fingerprint: voucher, verified: true)
+ #expect(manager.isVouched(fingerprint: vouchee))
+ }
+
+ @Test
+ func validVouchers_expireAtReadTime() {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+
+ let now = Date()
+ let timestamp = now.addingTimeInterval(-29 * 24 * 60 * 60)
+ #expect(manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: timestamp, now: now))
+ #expect(manager.isVouched(fingerprint: vouchee, now: now))
+
+ let twoDaysLater = now.addingTimeInterval(2 * 24 * 60 * 60)
+ #expect(manager.validVouchers(for: vouchee, now: twoDaysLater).isEmpty)
+ #expect(!manager.isVouched(fingerprint: vouchee, now: twoDaysLater))
+ }
+
+ @Test
+ func effectiveTrustLevel_slotsVouchedBetweenCasualAndTrusted() {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+
+ // Unknown peer with a valid vouch reads as vouched.
+ #expect(manager.effectiveTrustLevel(for: vouchee) == .unknown)
+ manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
+ #expect(manager.effectiveTrustLevel(for: vouchee) == .vouched)
+
+ // Explicit trust outranks a vouch.
+ manager.updateSocialIdentity(SocialIdentity(
+ fingerprint: vouchee,
+ localPetname: nil,
+ claimedNickname: "bob",
+ trustLevel: .trusted,
+ isFavorite: false,
+ isBlocked: false,
+ notes: nil
+ ))
+ #expect(manager.effectiveTrustLevel(for: vouchee) == .trusted)
+
+ // Explicit verification outranks everything.
+ manager.setVerified(fingerprint: vouchee, verified: true)
+ #expect(manager.effectiveTrustLevel(for: vouchee) == .verified)
+ #expect(!manager.isVouched(fingerprint: vouchee))
+
+ // Losing the voucher downgrades vouched back to the stored level.
+ manager.setVerified(fingerprint: vouchee, verified: false)
+ manager.setVerified(fingerprint: voucher, verified: false)
+ #expect(manager.effectiveTrustLevel(for: vouchee) == .casual)
+ }
+
+ // MARK: - Exchange-policy state
+
+ @Test
+ func mostRecentlyVerifiedFingerprints_ordersAndExcludes() {
+ let manager = makeManager()
+ let first = String(repeating: "01", count: 32)
+ let second = String(repeating: "02", count: 32)
+ let third = String(repeating: "03", count: 32)
+ manager.setVerified(fingerprint: first, verified: true)
+ manager.setVerified(fingerprint: second, verified: true)
+ manager.setVerified(fingerprint: third, verified: true)
+
+ let ordered = manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: third)
+ #expect(ordered == [second, first])
+
+ let limited = manager.mostRecentlyVerifiedFingerprints(limit: 1, excluding: third)
+ #expect(limited == [second])
+ }
+
+ @Test
+ func vouchBatchSentAt_roundTrips() {
+ let manager = makeManager()
+ #expect(manager.lastVouchBatchSent(to: voucher) == nil)
+
+ let sentAt = Date(timeIntervalSince1970: 1_700_000_000)
+ manager.markVouchBatchSent(to: voucher, at: sentAt)
+ #expect(manager.lastVouchBatchSent(to: voucher) == sentAt)
+ }
+
+ @Test
+ func signingPublicKey_returnsAnnounceBoundKeyByFingerprint() async {
+ let manager = makeManager()
+ let signingKey = Data(repeating: 0x22, count: 32)
+ manager.upsertCryptographicIdentity(
+ fingerprint: voucher,
+ noisePublicKey: Data(repeating: 0x11, count: 32),
+ signingPublicKey: signingKey,
+ claimedNickname: nil
+ )
+
+ let stored = await waitUntil { manager.signingPublicKey(forFingerprint: voucher) == signingKey }
+ #expect(stored)
+ #expect(manager.signingPublicKey(forFingerprint: vouchee) == nil)
+ }
+
+ // MARK: - Panic wipe
+
+ @Test
+ func clearAllIdentityData_wipesVouchState() async {
+ let manager = makeManager()
+ manager.setVerified(fingerprint: voucher, verified: true)
+ manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
+ manager.markVouchBatchSent(to: voucher, at: Date())
+ #expect(manager.isVouched(fingerprint: vouchee))
+
+ manager.clearAllIdentityData()
+
+ let wiped = await waitUntil { !manager.isVouched(fingerprint: vouchee) }
+ #expect(wiped)
+ #expect(manager.validVouchers(for: vouchee).isEmpty)
+ #expect(manager.lastVouchBatchSent(to: voucher) == nil)
+ #expect(manager.mostRecentlyVerifiedFingerprints(limit: 16, excluding: "").isEmpty)
+ }
+
+ // MARK: - Persistence compatibility
+
+ @Test
+ func trustLevelRawValuesAreStable() throws {
+ // Raw values are what's persisted; they must never change when cases
+ // are added mid-ladder.
+ #expect(TrustLevel.unknown.rawValue == "unknown")
+ #expect(TrustLevel.casual.rawValue == "casual")
+ #expect(TrustLevel.vouched.rawValue == "vouched")
+ #expect(TrustLevel.trusted.rawValue == "trusted")
+ #expect(TrustLevel.verified.rawValue == "verified")
+
+ let legacy = Data(#"["unknown","casual","trusted","verified"]"#.utf8)
+ let decoded = try JSONDecoder().decode([TrustLevel].self, from: legacy)
+ #expect(decoded == [.unknown, .casual, .trusted, .verified])
+ }
+
+ @Test
+ func identityCachePersistedBeforeVouchingDecodesCleanly() throws {
+ // A cache captured before the vouch fields existed must decode without
+ // tripping the "unreadable cache" recovery path.
+ let legacyJSON = Data("""
+ {
+ "socialIdentities": {},
+ "nicknameIndex": {},
+ "verifiedFingerprints": ["\(voucher)"],
+ "lastInteractions": {},
+ "blockedNostrPubkeys": [],
+ "version": 1
+ }
+ """.utf8)
+
+ let decoded = try JSONDecoder().decode(IdentityCache.self, from: legacyJSON)
+ #expect(decoded.vouchesByVouchee == nil)
+ #expect(decoded.vouchBatchSentAt == nil)
+ #expect(decoded.verifiedAt == nil)
+ #expect(decoded.verifiedFingerprints == [voucher])
+ }
+
+ @Test
+ func identityCacheRoundTripsVouchState() throws {
+ var cache = IdentityCache()
+ cache.verifiedFingerprints = [voucher]
+ cache.vouchesByVouchee = [vouchee: [VouchRecord(voucherFingerprint: voucher, timestamp: Date(timeIntervalSince1970: 1_700_000_000))]]
+ cache.vouchBatchSentAt = [voucher: Date(timeIntervalSince1970: 1_700_000_001)]
+ cache.verifiedAt = [voucher: Date(timeIntervalSince1970: 1_700_000_002)]
+
+ let decoded = try JSONDecoder().decode(IdentityCache.self, from: JSONEncoder().encode(cache))
+ #expect(decoded.vouchesByVouchee == cache.vouchesByVouchee)
+ #expect(decoded.vouchBatchSentAt == cache.vouchBatchSentAt)
+ #expect(decoded.verifiedAt == cache.verifiedAt)
+ }
+
+ @Test
+ func vouchStateSurvivesReload() async {
+ let keychain = MockKeychain()
+ let manager = SecureIdentityStateManager(keychain)
+ manager.setVerified(fingerprint: voucher, verified: true)
+ manager.recordVouch(voucheeFingerprint: vouchee, voucherFingerprint: voucher, timestamp: Date())
+ let saved = await waitUntil { manager.isVouched(fingerprint: self.vouchee) }
+ #expect(saved)
+ manager.forceSave()
+
+ let reloaded = SecureIdentityStateManager(keychain)
+ #expect(reloaded.isVouched(fingerprint: vouchee))
+ #expect(reloaded.validVouchers(for: vouchee).count == 1)
+ }
+
+ // MARK: - Helpers
+
+ private func waitUntil(
+ timeout: TimeInterval = 1.0,
+ condition: @escaping () -> Bool
+ ) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if condition() {
+ return true
+ }
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+ return condition()
+ }
+}
diff --git a/bitchatTests/Services/UnifiedNoticesTests.swift b/bitchatTests/Services/UnifiedNoticesTests.swift
new file mode 100644
index 00000000..3dd369b4
--- /dev/null
+++ b/bitchatTests/Services/UnifiedNoticesTests.swift
@@ -0,0 +1,142 @@
+//
+// UnifiedNoticesTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+import Testing
+@testable import bitchat
+
+struct UnifiedNoticesTests {
+
+ private let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
+ private var baseMs: UInt64 { UInt64(baseDate.timeIntervalSince1970 * 1000) }
+
+ private func makePost(
+ content: String,
+ nickname: String = "alice",
+ createdAt: UInt64? = nil,
+ urgent: Bool = false
+ ) -> BoardPostPacket {
+ BoardPostPacket(
+ postID: Data((0..<16).map { _ in UInt8.random(in: 0...255) }),
+ geohash: "9q8yy",
+ content: content,
+ authorSigningKey: Data(repeating: 1, count: 32),
+ authorNickname: nickname,
+ createdAt: createdAt ?? baseMs,
+ expiresAt: (createdAt ?? baseMs) + 24 * 60 * 60 * 1000,
+ flags: urgent ? BoardPostPacket.urgentFlag : 0,
+ signature: Data(repeating: 2, count: 64)
+ )
+ }
+
+ private func makeNote(
+ content: String,
+ nickname: String? = "alice",
+ createdAt: Date? = nil,
+ geohash: String = "9q8yy"
+ ) -> LocationNotesManager.Note {
+ LocationNotesManager.Note(
+ id: UUID().uuidString,
+ pubkey: "ab" + UUID().uuidString.replacingOccurrences(of: "-", with: ""),
+ content: content,
+ createdAt: createdAt ?? baseDate,
+ nickname: nickname,
+ geohash: geohash
+ )
+ }
+
+ @Test
+ func merge_dropsBridgedCopyOfBoardPost() {
+ let post = makePost(content: "free couch on 5th")
+ let bridged = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30))
+
+ let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
+
+ #expect(merged.count == 1)
+ #expect(merged[0].isBoardPost)
+ }
+
+ @Test
+ func merge_keepsNoteWithSameContentOutsideWindow() {
+ let post = makePost(content: "water station here")
+ let oldNote = makeNote(
+ content: "water station here",
+ createdAt: baseDate.addingTimeInterval(-UnifiedNotices.bridgeDedupeWindow - 60)
+ )
+
+ let merged = UnifiedNotices.merge(posts: [post], notes: [oldNote])
+
+ #expect(merged.count == 2)
+ }
+
+ @Test
+ func merge_keepsSameTextNoteFromNeighborCell() {
+ // The notes subscription covers the center cell plus 8 neighbors; a
+ // matching note posted to a *neighbor* is not the bridged copy.
+ let post = makePost(content: "free couch on 5th")
+ let neighborNote = makeNote(content: "free couch on 5th", createdAt: baseDate.addingTimeInterval(30), geohash: "9q8yz")
+
+ let merged = UnifiedNotices.merge(posts: [post], notes: [neighborNote])
+
+ #expect(merged.count == 2)
+ }
+
+ @Test
+ func merge_keepsNoteFromDifferentAuthor() {
+ let post = makePost(content: "meetup at 6", nickname: "alice")
+ let note = makeNote(content: "meetup at 6", nickname: "bob")
+
+ let merged = UnifiedNotices.merge(posts: [post], notes: [note])
+
+ #expect(merged.count == 2)
+ }
+
+ @Test
+ func merge_sortsUrgentFirstThenNewest() {
+ let urgent = makePost(content: "road closed", createdAt: baseMs - 60_000, urgent: true)
+ let newerPost = makePost(content: "later post", createdAt: baseMs)
+ let note = makeNote(content: "a note", nickname: "carol", createdAt: baseDate.addingTimeInterval(30))
+
+ let merged = UnifiedNotices.merge(posts: [newerPost, urgent], notes: [note])
+
+ #expect(merged.map(\.content) == ["road closed", "a note", "later post"])
+ #expect(merged[0].isUrgent)
+ }
+
+ @Test
+ func merge_anonNicknamesMatchForDedupe() {
+ // Bridged posts from an empty nickname arrive as anon notes with no
+ // "n" tag; they must still dedupe against the anon board copy.
+ let post = makePost(content: "hello", nickname: "")
+ let bridged = makeNote(content: "hello", nickname: nil)
+
+ let merged = UnifiedNotices.merge(posts: [post], notes: [bridged])
+
+ #expect(merged.count == 1)
+ #expect(merged[0].isBoardPost)
+ #expect(merged[0].author == "anon")
+ }
+
+ @Test
+ func noticeItem_normalizesNoteDisplayName() {
+ let note = LocationNotesManager.Note(
+ id: "e1",
+ pubkey: "deadbeef",
+ content: "hi",
+ createdAt: baseDate,
+ nickname: "dave",
+ geohash: "9q8yy"
+ )
+
+ let item = NoticeItem(note: note)
+
+ #expect(item.author == "dave")
+ #expect(!item.isBoardPost)
+ #expect(!item.isUrgent)
+ }
+}
diff --git a/bitchatTests/Services/UnifiedPeerServiceTests.swift b/bitchatTests/Services/UnifiedPeerServiceTests.swift
index f5a5fd6c..172bf5b1 100644
--- a/bitchatTests/Services/UnifiedPeerServiceTests.swift
+++ b/bitchatTests/Services/UnifiedPeerServiceTests.swift
@@ -292,4 +292,37 @@ private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
func getVerifiedFingerprints() -> Set {
verified
}
+
+ // MARK: Vouching (unused by these tests)
+
+ @discardableResult
+ func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
+ false
+ }
+
+ func validVouchers(for fingerprint: String) -> [VouchRecord] {
+ []
+ }
+
+ func isVouched(fingerprint: String) -> Bool {
+ false
+ }
+
+ func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
+ verified.contains(fingerprint) ? .verified : .unknown
+ }
+
+ func lastVouchBatchSent(to fingerprint: String) -> Date? {
+ nil
+ }
+
+ func markVouchBatchSent(to fingerprint: String, at date: Date) {}
+
+ func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
+ nil
+ }
+
+ func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
+ []
+ }
}
diff --git a/bitchatTests/Sync/GossipSyncBoardTests.swift b/bitchatTests/Sync/GossipSyncBoardTests.swift
new file mode 100644
index 00000000..7fe8bdcf
--- /dev/null
+++ b/bitchatTests/Sync/GossipSyncBoardTests.swift
@@ -0,0 +1,131 @@
+//
+// GossipSyncBoardTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitFoundation
+import Foundation
+import Testing
+@testable import bitchat
+
+/// Board posts ride gossip sync through a provider that queries the board
+/// store, so retention (expiry, tombstones, caps) has a single owner.
+struct GossipSyncBoardTests {
+
+ private let myPeerID = PeerID(str: "0102030405060708")
+
+ private func makeBoardPacket(timestamp: UInt64) throws -> BitchatPacket {
+ BitchatPacket(
+ type: MessageType.boardPost.rawValue,
+ senderID: try #require(Data(hexString: "aabbccddeeff0011")),
+ recipientID: nil,
+ timestamp: timestamp,
+ payload: Data([0x42]),
+ signature: nil,
+ ttl: 7
+ )
+ }
+
+ private func quietConfig() -> GossipSyncManager.Config {
+ var config = GossipSyncManager.Config()
+ config.messageSyncIntervalSeconds = 0
+ config.fragmentSyncIntervalSeconds = 0
+ config.fileTransferSyncIntervalSeconds = 0
+ config.prekeyBundleSyncIntervalSeconds = 0
+ return config
+ }
+
+ @Test func boardRequestIsServedFromProvider() async throws {
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
+ let delegate = RecordingBoardDelegate()
+ manager.delegate = delegate
+ let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
+ manager.boardPacketsProvider = { return [boardPacket] }
+
+ let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
+ manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
+
+ try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
+ let sent = try #require(delegate.packets.first)
+ #expect(sent.type == MessageType.boardPost.rawValue)
+ #expect(sent.isRSR)
+ }
+
+ @Test func nonBoardRequestDoesNotServeBoardPackets() async throws {
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: quietConfig(), requestSyncManager: RequestSyncManager())
+ let delegate = RecordingBoardDelegate()
+ manager.delegate = delegate
+ let boardPacket = try makeBoardPacket(timestamp: UInt64(Date().timeIntervalSince1970 * 1000))
+ manager.boardPacketsProvider = { return [boardPacket] }
+
+ let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .publicMessages)
+ manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: request)
+
+ // Follow with a board request; only its response should arrive, which
+ // also proves the first request produced nothing.
+ let boardRequest = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .board)
+ manager.handleRequestSync(from: PeerID(str: "FFFFFFFFFFFFFFFF"), request: boardRequest)
+
+ try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
+ #expect(delegate.packets.count == 1)
+ #expect(delegate.packets.first?.type == MessageType.boardPost.rawValue)
+ }
+
+ @Test func maintenanceEmitsBoardRoundOnlyWithProvider() throws {
+ var config = quietConfig()
+ config.boardSyncIntervalSeconds = 1
+
+ // Without a provider the board schedule stays silent.
+ let unwired = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
+ let unwiredDelegate = RecordingBoardDelegate()
+ unwired.delegate = unwiredDelegate
+ unwired._performMaintenanceSynchronously(now: Date())
+ #expect(unwiredDelegate.packets.isEmpty)
+
+ // With a provider, maintenance sends a board-typed request.
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: RequestSyncManager())
+ let delegate = RecordingBoardDelegate()
+ manager.delegate = delegate
+ manager.boardPacketsProvider = { return [] }
+ manager._performMaintenanceSynchronously(now: Date())
+
+ #expect(delegate.packets.count == 1)
+ let payload = try #require(delegate.packets.first?.payload)
+ let request = try #require(RequestSyncPacket.decode(from: payload))
+ #expect(request.types == .board)
+ }
+}
+
+private final class RecordingBoardDelegate: GossipSyncManager.Delegate {
+ private let lock = NSLock()
+ private var _packets: [BitchatPacket] = []
+
+ var packets: [BitchatPacket] {
+ lock.lock()
+ defer { lock.unlock() }
+ return _packets
+ }
+
+ func sendPacket(_ packet: BitchatPacket) {
+ lock.lock()
+ _packets.append(packet)
+ lock.unlock()
+ }
+
+ func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
+ lock.lock()
+ _packets.append(packet)
+ lock.unlock()
+ }
+
+ func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
+ packet
+ }
+
+ func getConnectedPeers() -> [PeerID] {
+ []
+ }
+}
diff --git a/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift
new file mode 100644
index 00000000..d055bde6
--- /dev/null
+++ b/bitchatTests/Sync/RequestSyncPacketFragmentFilterTests.swift
@@ -0,0 +1,76 @@
+//
+// RequestSyncPacketFragmentFilterTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+import BitFoundation
+@testable import bitchat
+
+struct RequestSyncPacketFragmentFilterTests {
+
+ @Test func fragmentIdFilterRoundTripsThroughWireEncoding() throws {
+ let id1 = try #require(Data(hexString: "00112233445566aa"))
+ let id2 = try #require(Data(hexString: "ffeeddccbbaa9988"))
+ let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([id1, id2]))
+
+ let packet = RequestSyncPacket(p: 7, m: 128, data: Data([0x01]), types: .fragment, fragmentIdFilter: filter)
+ let decoded = try #require(RequestSyncPacket.decode(from: packet.encode()))
+
+ #expect(decoded.fragmentIdFilter == filter)
+ let ids = try #require(RequestSyncPacket.decodeFragmentIdFilter(decoded.fragmentIdFilter))
+ #expect(ids == Set([id1, id2]))
+ }
+
+ @Test func encodeCapsFilterAtMaxCountWithinDecoderBudget() throws {
+ let ids = (0..<100).map { i -> Data in
+ var id = Data(repeating: 0, count: 8)
+ id[7] = UInt8(i)
+ return id
+ }
+ let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter(ids))
+
+ let tokens = filter.split(separator: ",")
+ #expect(tokens.count == RequestSyncPacket.maxFragmentIdFilterCount)
+ // 60 IDs * 17 bytes ("<16 hex>,") - 1 = 1019 ≤ the 1024-byte cap.
+ #expect(filter.utf8.count == 1019)
+ #expect(filter.utf8.count <= 1024)
+ }
+
+ @Test func encodeDropsMalformedIDs() throws {
+ let good = try #require(Data(hexString: "0011223344556677"))
+ let short = Data([0x01, 0x02])
+ let filter = try #require(RequestSyncPacket.encodeFragmentIdFilter([short, good]))
+ #expect(filter == good.hexEncodedString())
+ #expect(RequestSyncPacket.encodeFragmentIdFilter([short]) == nil)
+ #expect(RequestSyncPacket.encodeFragmentIdFilter([]) == nil)
+ }
+
+ @Test func decodeIgnoresMalformedTokens() throws {
+ let good = try #require(Data(hexString: "0011223344556677"))
+ let ids = try #require(
+ RequestSyncPacket.decodeFragmentIdFilter("zzzz,0011,0011223344556677,")
+ )
+ #expect(ids == Set([good]))
+ #expect(RequestSyncPacket.decodeFragmentIdFilter(nil) == nil)
+ #expect(RequestSyncPacket.decodeFragmentIdFilter("not-hex") == nil)
+ }
+
+ @Test func decoderIgnoresOversizedFilterValue() throws {
+ // Hand-roll a payload whose 0x06 TLV exceeds the acceptance cap; the
+ // request must still decode, with the filter dropped.
+ var payload = RequestSyncPacket(p: 7, m: 128, data: Data([0x01])).encode()
+ let oversized = Data(repeating: UInt8(ascii: "a"), count: 1025)
+ payload.append(0x06)
+ payload.append(UInt8((oversized.count >> 8) & 0xFF))
+ payload.append(UInt8(oversized.count & 0xFF))
+ payload.append(oversized)
+
+ let decoded = try #require(RequestSyncPacket.decode(from: payload))
+ #expect(decoded.fragmentIdFilter == nil)
+ }
+}
diff --git a/bitchatTests/Sync/SyncResponseRateLimiterTests.swift b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift
new file mode 100644
index 00000000..63f5329a
--- /dev/null
+++ b/bitchatTests/Sync/SyncResponseRateLimiterTests.swift
@@ -0,0 +1,61 @@
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+struct SyncResponseRateLimiterTests {
+
+ private let peer = PeerID(str: "1122334455667788")
+ private let otherPeer = PeerID(str: "8899aabbccddeeff")
+
+ @Test func allowsResponsesUpToBudgetThenBlocks() {
+ var limiter = SyncResponseRateLimiter(maxResponses: 2, window: 30)
+ let now = Date()
+
+ let first = limiter.shouldRespond(to: peer, now: now)
+ let second = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(1))
+ let third = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(2))
+
+ #expect(first)
+ #expect(second)
+ #expect(!third)
+ }
+
+ @Test func budgetIsPerPeer() {
+ var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30)
+ let now = Date()
+
+ let first = limiter.shouldRespond(to: peer, now: now)
+ let repeated = limiter.shouldRespond(to: peer, now: now)
+ let other = limiter.shouldRespond(to: otherPeer, now: now)
+
+ #expect(first)
+ #expect(!repeated)
+ #expect(other)
+ }
+
+ @Test func allowsAgainAfterWindowSlides() {
+ var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30)
+ let now = Date()
+
+ let first = limiter.shouldRespond(to: peer, now: now)
+ let insideWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(29))
+ let afterWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(31))
+
+ #expect(first)
+ #expect(!insideWindow)
+ #expect(afterWindow)
+ }
+
+ @Test func pruneDropsExpiredHistory() {
+ var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30)
+ let now = Date()
+
+ let first = limiter.shouldRespond(to: peer, now: now)
+ limiter.prune(now: now.addingTimeInterval(31))
+ let afterPrune = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(32))
+
+ #expect(first)
+ #expect(afterPrune)
+ }
+}
diff --git a/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift
new file mode 100644
index 00000000..829bfd4d
--- /dev/null
+++ b/bitchatTests/Sync/SyncTypeFlagsBoardTests.swift
@@ -0,0 +1,79 @@
+//
+// SyncTypeFlagsBoardTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitFoundation
+import Foundation
+import Testing
+@testable import bitchat
+
+/// The board sync flag is the first bit outside the original single byte of
+/// type flags. These tests pin down the wire compatibility contract: the
+/// types TLV has been a variable-length (1-8 byte) little-endian bitfield
+/// since type-aware sync, so widening to two bytes must decode everywhere
+/// and unknown bits must be ignored, not rejected.
+struct SyncTypeFlagsBoardTests {
+
+ @Test func boardFlagEncodesIntoSecondByte() throws {
+ let data = try #require(SyncTypeFlags.board.toData())
+ // Little-endian: low byte first, board bit (bit 8) in byte 2.
+ #expect(data == Data([0x00, 0x01]))
+ }
+
+ @Test func boardFlagRoundTrips() throws {
+ let flags = SyncTypeFlags(messageTypes: [.message, .boardPost])
+ let data = try #require(flags.toData())
+ let decoded = try #require(SyncTypeFlags.decode(data))
+ #expect(decoded.contains(.message))
+ #expect(decoded.contains(.boardPost))
+ #expect(!decoded.contains(.fragment))
+ #expect(Set(decoded.toMessageTypes()) == Set([.message, .boardPost]))
+ }
+
+ /// An old decoder is modeled by bits it has no mapping for: the shared
+ /// decode path accepts the bytes and simply maps unknown bits to no
+ /// message type, so a board-only request reads as "nothing I can serve".
+ @Test func unknownBitsDecodeToNoTypes() throws {
+ // Bits 11-15 are unassigned (bit 8 = board, bit 9 = prekeyBundle,
+ // bit 10 = groupMessage); a future (or unknown) two-byte bitfield must
+ // decode without error and yield no known types.
+ let decoded = try #require(SyncTypeFlags.decode(Data([0x00, 0xF8])))
+ #expect(decoded.toMessageTypes().isEmpty)
+ for type in [MessageType.announce, .message, .fragment, .fileTransfer, .boardPost, .prekeyBundle, .groupMessage] {
+ #expect(!decoded.contains(type))
+ }
+ }
+
+ @Test func mixedKnownAndUnknownBitsKeepKnownTypes() throws {
+ // Known low-byte flags survive alongside unknown high bits (11-15).
+ let decoded = try #require(SyncTypeFlags.decode(Data([0x03, 0xF8])))
+ #expect(decoded.contains(.announce))
+ #expect(decoded.contains(.message))
+ #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message]))
+ }
+
+ @Test func requestSyncPacketRoundTripsBoardFlag() throws {
+ let request = RequestSyncPacket(
+ p: 4,
+ m: 128,
+ data: Data([0xAB, 0xCD]),
+ types: SyncTypeFlags(messageTypes: [.boardPost])
+ )
+ let decoded = try #require(RequestSyncPacket.decode(from: request.encode()))
+ let types = try #require(decoded.types)
+ #expect(types.contains(.boardPost))
+ #expect(!types.contains(.message))
+ }
+
+ @Test func singleByteLegacyEncodingStillDecodes() throws {
+ // Requests from old clients keep the one-byte bitfield.
+ let decoded = try #require(SyncTypeFlags.decode(Data([0x03])))
+ #expect(decoded.contains(.announce))
+ #expect(decoded.contains(.message))
+ #expect(!decoded.contains(.boardPost))
+ }
+}
diff --git a/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift
new file mode 100644
index 00000000..2bd9d53b
--- /dev/null
+++ b/bitchatTests/Sync/SyncTypeFlagsGroupTests.swift
@@ -0,0 +1,62 @@
+//
+// SyncTypeFlagsGroupTests.swift
+// bitchat
+//
+// Wire-compat proof for the groupMessage sync bit (bit 10): the types
+// bitfield widens from 1 to 2 bytes, and clients that don't know the bit
+// simply ignore it.
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+struct SyncTypeFlagsGroupTests {
+
+ @Test func groupMessageOccupiesBitTen() {
+ #expect(SyncTypeFlags.groupMessage.rawValue == 1 << 10)
+ #expect(SyncTypeFlags.groupMessage.contains(.groupMessage))
+ #expect(!SyncTypeFlags.publicMessages.contains(.groupMessage))
+ }
+
+ @Test func extendedBitfieldWidensToTwoBytes() throws {
+ // Legacy flags fit one byte…
+ #expect(SyncTypeFlags.publicMessages.toData() == Data([0x03]))
+
+ // …the group bit widens the little-endian encoding to two bytes.
+ let combined = SyncTypeFlags.publicMessages.union(.groupMessage)
+ let encoded = try #require(combined.toData())
+ #expect(encoded == Data([0x03, 0x04]))
+
+ let decoded = try #require(SyncTypeFlags.decode(encoded))
+ #expect(decoded == combined)
+ #expect(Set(decoded.toMessageTypes()) == Set([.announce, .message, .groupMessage]))
+ }
+
+ @Test func unknownBitsAreIgnoredNotRejected() throws {
+ // An "old client" reading a 2-byte field keeps the raw bits but maps
+ // unknown bit indices to no message type — it answers with the types
+ // it knows instead of dropping the request.
+ let futuristic = try #require(SyncTypeFlags.decode(Data([0x03, 0xFC])))
+ #expect(Set(futuristic.toMessageTypes()) == Set([.announce, .message, .groupMessage]))
+ #expect(futuristic.contains(.announce))
+ #expect(futuristic.contains(.message))
+ }
+
+ @Test func requestSyncPacketRoundTripsGroupFlag() throws {
+ let types = SyncTypeFlags.publicMessages.union(.groupMessage)
+ let packet = RequestSyncPacket(p: 8, m: 1024, data: Data([0xAB, 0xCD]), types: types)
+ let encoded = packet.encode()
+
+ let decoded = try #require(RequestSyncPacket.decode(from: encoded))
+ #expect(decoded.types == types)
+ #expect(decoded.types?.contains(.groupMessage) == true)
+ #expect(decoded.p == 8)
+ #expect(decoded.m == 1024)
+ #expect(decoded.data == Data([0xAB, 0xCD]))
+ }
+}
diff --git a/bitchatTests/Sync/SyncTypeFlagsTests.swift b/bitchatTests/Sync/SyncTypeFlagsTests.swift
new file mode 100644
index 00000000..8c400119
--- /dev/null
+++ b/bitchatTests/Sync/SyncTypeFlagsTests.swift
@@ -0,0 +1,56 @@
+import Foundation
+import Testing
+import BitFoundation
+@testable import bitchat
+
+struct SyncTypeFlagsTests {
+
+ @Test func knownTypesRoundTripThroughData() throws {
+ let flags: SyncTypeFlags = [.announce, .message, .fragment, .fileTransfer]
+ let data = try #require(flags.toData())
+ let decoded = try #require(SyncTypeFlags.decode(data))
+ #expect(decoded == flags)
+ }
+
+ @Test func decodeDropsPhantomBits() {
+ // Bits 11+ map to no message type (bit 8 = boardPost, bit 9 =
+ // prekeyBundle, bit 10 = groupMessage). They must not survive decode
+ // as phantom membership.
+ let phantom = Data([0x00, 0xF8]) // bits 11..15 set, no known type
+ let decoded = SyncTypeFlags.decode(phantom)
+ #expect(decoded?.rawValue == 0)
+ #expect(decoded?.toMessageTypes().isEmpty == true)
+ }
+
+ @Test func boardBitSurvivesDecode() {
+ // Bit 8 maps to boardPost and spills the field into a second byte;
+ // it must survive decode while the phantom high bits (11+) are
+ // stripped. Bits 9 (prekeyBundle) and 10 (groupMessage) are cleared
+ // to isolate the board bit.
+ let mixed = Data([0x00, 0xF9]) // bit 8 (board) known, bits 11..15 phantom
+ let decoded = SyncTypeFlags.decode(mixed)
+ #expect(decoded?.contains(.board) == true)
+ #expect(decoded?.rawValue == 0b1_0000_0000)
+ }
+
+ @Test func phantomBitsAreStrippedButKnownBitsSurvive() {
+ // Low byte = announce(0) + message(1); high byte bits 11+ are phantom.
+ let mixed = Data([0b0000_0011, 0xF8])
+ let decoded = SyncTypeFlags.decode(mixed)
+ #expect(decoded?.contains(.announce) == true)
+ #expect(decoded?.contains(.message) == true)
+ // Only the two known bits remain; phantom high bits are gone.
+ #expect(decoded?.rawValue == 0b0000_0011)
+ }
+
+ @Test func rawValueInitNormalizesPhantomBits() {
+ let flags = SyncTypeFlags(rawValue: 0xFFFF_FFFF_FFFF_FFFF)
+ // Every known type bit is set; nothing above them survives. boardPost
+ // occupies bit 8, so the known set spills into a second byte.
+ #expect(flags.contains(.announce))
+ #expect(flags.contains(.fileTransfer))
+ #expect(flags.contains(.board))
+ let data = flags.toData()
+ #expect(data?.count == 2)
+ }
+}
diff --git a/bitchatTests/ViewSmokeTests.swift b/bitchatTests/ViewSmokeTests.swift
index 786b481e..debb41e5 100644
--- a/bitchatTests/ViewSmokeTests.swift
+++ b/bitchatTests/ViewSmokeTests.swift
@@ -1,3 +1,4 @@
+import Combine
import Testing
import Foundation
import SwiftUI
@@ -39,6 +40,7 @@ private struct SmokeFeatureModels {
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel
+ let boardAlertsModel: BoardAlertsModel
}
@MainActor
@@ -80,6 +82,14 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
locationChannelsModel: locationChannelsModel
)
+ let boardAlertsModel = BoardAlertsModel(
+ arrivals: Empty(completeImmediately: false).eraseToAnyPublisher(),
+ dependencies: BoardAlertsModel.Dependencies(
+ isOwnPost: { _ in false },
+ emitSystemLine: { _, _ in }
+ )
+ )
+
return SmokeFeatureModels(
publicChatModel: publicChatModel,
appChromeModel: appChromeModel,
@@ -88,7 +98,8 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
privateConversationModel: privateConversationModel,
verificationModel: verificationModel,
conversationUIModel: conversationUIModel,
- peerListModel: peerListModel
+ peerListModel: peerListModel,
+ boardAlertsModel: boardAlertsModel
)
}
@@ -106,6 +117,7 @@ private func installSmokeEnvironment(
.environmentObject(featureModels.verificationModel)
.environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel)
+ .environmentObject(featureModels.boardAlertsModel)
}
@MainActor
@@ -395,9 +407,17 @@ struct ViewSmokeTests {
}
@Test
- func locationNotesView_rendersNoRelayAndLoadedStates() throws {
- let (viewModel, _, _) = makeSmokeViewModel()
+ func noticesView_rendersNoRelayAndLoadedStates() throws {
+ let (viewModel, transport, _) = makeSmokeViewModel()
let featureModels = makeSmokeFeatureModels(for: viewModel)
+ featureModels.locationChannelsModel.select(.location(GeohashChannel(level: .building, geohash: "u4pruydq")))
+ defer { featureModels.locationChannelsModel.select(.mesh) }
+ let board = BoardManager(
+ transport: transport,
+ store: BoardStore(persistsToDisk: false, fileURL: nil, now: { Date() }),
+ publishToNostr: { _, _, _, _ in nil },
+ deleteFromNostr: { _, _ in }
+ )
let noRelayManager = LocationNotesManager(
geohash: "u4pruydq",
@@ -440,18 +460,28 @@ struct ViewSmokeTests {
eose?()
_ = mount(
- LocationNotesView(
- geohash: "u4pruydq",
+ NoticesView(
senderNickname: viewModel.nickname,
- manager: noRelayManager
+ board: board,
+ initialTab: .geo,
+ notesManager: noRelayManager
)
.environmentObject(featureModels.locationChannelsModel)
)
_ = mount(
- LocationNotesView(
- geohash: "u4pruydq",
+ NoticesView(
senderNickname: viewModel.nickname,
- manager: loadedManager
+ board: board,
+ initialTab: .geo,
+ notesManager: loadedManager
+ )
+ .environmentObject(featureModels.locationChannelsModel)
+ )
+ _ = mount(
+ NoticesView(
+ senderNickname: viewModel.nickname,
+ board: board,
+ initialTab: .mesh
)
.environmentObject(featureModels.locationChannelsModel)
)
diff --git a/docs/REQUEST_SYNC_MANAGER.md b/docs/REQUEST_SYNC_MANAGER.md
index b304508e..a885f159 100644
--- a/docs/REQUEST_SYNC_MANAGER.md
+++ b/docs/REQUEST_SYNC_MANAGER.md
@@ -20,9 +20,11 @@ The new implementation introduces a **RequestSyncManager** to track outgoing syn
### Request Sync Payload
The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include:
-* **Future Filters**:
- * `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian).
- * `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string).
+* `sinceTimestamp` (Type 0x05): filter-coverage cursor (UInt64 big-endian). The requester's GCS filter only covers packets at or after this timestamp; the responder skips older packets instead of re-sending them every round.
+* `fragmentIdFilter` (Type 0x06): targeted fragment resync (UTF-8 string). Comma-separated 16-hex-char (8-byte) fragment **stream IDs** — the ID that prefixes every fragment payload.
+ * **Requester**: when a broadcast reassembly stalls (no new fragment for 5 s), the fragment assembler reports the stream ID and a `REQUEST_SYNC` with `types = fragment` and this filter goes to each connected peer (re-requested at most every 10 s per stream). Directed reassemblies are excluded — peers only archive broadcast fragments for sync.
+ * **Responder**: when the filter is present, the fragment diff is restricted to exactly the named streams and the `sinceTimestamp` cursor is bypassed for them; the GCS filter still excludes pieces the requester already holds. Responses keep RSR marking, TTL 0, per-peer response rate limiting (8/30 s), and `REQUEST_SYNC` itself remains link-local (TTL 0, never relayed).
+ * **Bounds**: at most 60 IDs per request. Each ID encodes as 16 hex chars plus a comma separator, so the largest value is 60 × 17 − 1 = 1019 bytes, within the decoder's 1024-byte acceptance cap; oversized filter values are ignored (the rest of the request still decodes).
## Architecture
diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md
index 6832a16e..55d00232 100644
--- a/docs/SOURCE_ROUTING.md
+++ b/docs/SOURCE_ROUTING.md
@@ -2,7 +2,7 @@
This document specifies the Source-Based Routing extension (v2) for the BitChat protocol. This upgrade enables efficient unicast routing across the mesh by allowing senders to specify an explicit path of intermediate relays.
-**Status:** Implemented in Android and iOS. Backward compatible (v1 clients ignore routing data).
+**Status:** Implemented in Android and iOS: both decode routed packets, forward along routes, and originate routes. iOS origination is policy-gated (see §8). Backward compatible (v1 clients never receive routed frames from iOS: routes are only originated when every node on the path has been observed speaking v2).
---
@@ -144,3 +144,44 @@ When a node receives a packet **not** addressed to itself:
* **Fallback:** If the Next Hop is unreachable, **fall back to broadcast/flood** to ensure delivery.
3. **If NO (Standard):**
* Flood the packet to all connected neighbors (subject to TTL and probability rules).
+
+---
+
+## 8. iOS Origination Policy
+
+iOS attaches a route (upgrading the packet to v2 and re-signing it) only when
+**all** of the following hold at send time (`BLESourceRouteOriginationPolicy`):
+
+1. **Authored locally.** The packet's `SenderID` is our own peer ID. Relays
+ never rewrite someone else's packet — adding a route would force a
+ re-sign under the wrong key. Relays only *follow* existing routes
+ (`BLERouteForwardingPolicy`).
+2. **Directed.** The packet has a single-peer `RecipientID` (not the
+ broadcast ID). In practice this covers Noise-encrypted private traffic,
+ private file transfers, and their fragments (fragments inherit the
+ parent's route and version, per §5).
+3. **TTL headroom.** `TTL > 1`. Link-local packets (e.g. `REQUEST_SYNC`,
+ always TTL 0) never carry routes.
+4. **Recipient not directly connected.** A direct write already delivers in
+ one hop; a route would only add bytes.
+5. **Complete v2 path exists.** BFS over the confirmed-edge mesh graph
+ (`MeshTopologyTracker`, built from verified announce `directNeighbors`
+ claims, entries expiring after 60 s) finds a path with **at most 4
+ intermediate hops** where every intermediate hop **and the recipient**
+ has been observed originating or relaying a v2 packet. Nodes never seen
+ speaking v2 are assumed v1-only and are excluded — a v1 client cannot
+ decode a v2 frame, so routing through it would silently drop the packet.
+6. **No recent route failure.** See below.
+
+If any gate fails, behavior is exactly the pre-routing flood/direct-write
+path — v1 peers observe no change.
+
+### Failure Fallback
+
+A routed unicast rides one path; a broken hop loses the packet where a flood
+would heal around it. iOS keeps a small per-recipient health cache
+(`BLESourceRouteFailureCache`): a routed send that sees no inbound packet
+authored by the recipient within 10 s counts as a route failure, and directed
+sends to that recipient fall back to flooding for the next 60 s before
+routing is attempted again. Retransmission of the payload itself stays where
+it always was (MessageRouter and higher layers).
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift
index b46a552d..ca29a2b2 100644
--- a/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift
+++ b/localPackages/BitFoundation/Sources/BitFoundation/BitchatPacket.swift
@@ -14,7 +14,7 @@ import struct Foundation.Date
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
public struct BitchatPacket: Codable {
- let version: UInt8
+ public let version: UInt8
public let type: UInt8
public let senderID: Data
public let recipientID: Data?
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift
index 4bb99de3..acba7bf7 100644
--- a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift
+++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift
@@ -24,23 +24,46 @@ public struct CourierEnvelope: Equatable {
public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data
+ /// Spray-and-wait copy budget: how many redundant copies of this envelope
+ /// the holder may still hand to other couriers (binary split on each
+ /// spray). 1 means carry-only — deliver to the recipient, never re-spray.
+ public let copies: UInt8
+ /// Seal-format discriminator: nil means v1 (ciphertext is one-way Noise X
+ /// to the recipient's *static* key); a value means v2 (Noise X to the
+ /// recipient's one-time prekey with this ID, forward secret). Encoded as
+ /// an optional TLV so v1 decoders skip it as unknown: an old client still
+ /// carries and hands over v2 envelopes opaquely, and when one is addressed
+ /// to it the static-key open simply fails and is dropped quietly.
+ public let prekeyID: UInt32?
public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
+ /// Cap on the copy budget a depositor can claim, so a malicious envelope
+ /// cannot turn the courier network into an amplifier.
+ public static let maxCopies: UInt8 = 8
private enum TLVType: UInt8 {
case recipientTag = 0x01
case expiry = 0x02
case ciphertext = 0x03
+ case copies = 0x04
+ case prekeyID = 0x05
}
- public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
+ public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1, prekeyID: UInt32? = nil) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
+ self.copies = min(max(copies, 1), Self.maxCopies)
+ self.prekeyID = prekeyID
+ }
+
+ /// The same envelope with a different remaining copy budget.
+ public func withCopies(_ copies: UInt8) -> CourierEnvelope {
+ CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
}
public var isExpired: Bool {
@@ -75,6 +98,22 @@ public struct CourierEnvelope: Equatable {
appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext)
+ // Omitted when 1 so carry-only envelopes stay byte-identical to the
+ // pre-spray wire format (old clients skip the TLV as unknown anyway).
+ if copies > 1 {
+ encoded.append(TLVType.copies.rawValue)
+ appendBE(UInt16(1), into: &encoded)
+ encoded.append(copies)
+ }
+
+ // Omitted for v1 static-sealed envelopes so they stay byte-identical
+ // to the pre-prekey wire format.
+ if let prekeyID {
+ encoded.append(TLVType.prekeyID.rawValue)
+ appendBE(UInt16(4), into: &encoded)
+ appendBE(prekeyID, into: &encoded)
+ }
+
return encoded
}
@@ -85,6 +124,8 @@ public struct CourierEnvelope: Equatable {
var recipientTag: Data?
var expiry: UInt64?
var ciphertext: Data?
+ var copies: UInt8 = 1
+ var prekeyID: UInt32?
while cursor < end {
let typeRaw = data[cursor]
@@ -107,6 +148,12 @@ public struct CourierEnvelope: Equatable {
case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value)
+ case .copies:
+ guard length == 1 else { return nil }
+ copies = value.first ?? 1
+ case .prekeyID:
+ guard length == 4 else { return nil }
+ prekeyID = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
case nil:
// Unknown TLV: skip for forward compatibility.
continue
@@ -114,7 +161,7 @@ public struct CourierEnvelope: Equatable {
}
guard let recipientTag, let expiry, let ciphertext else { return nil }
- return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
+ return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
}
// MARK: - Recipient Tags
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift
new file mode 100644
index 00000000..54265b8c
--- /dev/null
+++ b/localPackages/BitFoundation/Sources/BitFoundation/MeshPingPayload.swift
@@ -0,0 +1,57 @@
+//
+// MeshPingPayload.swift
+// BitFoundation
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import struct Foundation.Data
+
+/// Wire payload shared by the `ping` (0x26) and `pong` (0x27) message types.
+///
+/// Layout (9 bytes):
+/// - 8 bytes: random nonce (a pong echoes the nonce of the ping it answers)
+/// - 1 byte: origin TTL — the TTL the packet was launched with, so the
+/// receiver can compute the hop count as `originTTL - receivedTTL`.
+///
+/// Both directions are unencrypted and unsigned: the payload carries no
+/// private data, and the unguessable nonce already binds a pong to a probe
+/// the local device actually sent.
+public struct MeshPingPayload: Equatable {
+ public static let nonceLength = 8
+ private static let encodedLength = nonceLength + 1
+
+ public let nonce: Data
+ public let originTTL: UInt8
+
+ public init?(nonce: Data, originTTL: UInt8) {
+ guard nonce.count == Self.nonceLength else { return nil }
+ self.nonce = nonce
+ self.originTTL = originTTL
+ }
+
+ public func encode() -> Data {
+ var data = Data(capacity: Self.encodedLength)
+ data.append(nonce)
+ data.append(originTTL)
+ return data
+ }
+
+ /// Accepts payloads with trailing bytes so future revisions can extend
+ /// the format without breaking older clients.
+ public static func decode(_ data: Data) -> MeshPingPayload? {
+ guard data.count >= encodedLength else { return nil }
+ let nonce = Data(data.prefix(nonceLength))
+ let originTTL = data[data.index(data.startIndex, offsetBy: nonceLength)]
+ return MeshPingPayload(nonce: nonce, originTTL: originTTL)
+ }
+
+ /// Number of links a packet crossed, derived from TTL decrements plus the
+ /// final delivery link (a directly connected peer is 1 hop away).
+ /// Returns nil when the TTLs are inconsistent (received above origin).
+ public static func hopCount(originTTL: UInt8, receivedTTL: UInt8) -> Int? {
+ guard originTTL >= receivedTTL else { return nil }
+ return Int(originTTL - receivedTTL) + 1
+ }
+}
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
index ceac0cd0..4ddf5ab1 100644
--- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
+++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift
@@ -7,7 +7,7 @@
//
/// Simplified BitChat protocol message types.
-/// Reduced from 24 types to just 6 essential ones.
+/// Consolidated from the original 24 wire types down to the 9 cases below.
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
public enum MessageType: UInt8 {
// Public messages (unencrypted)
@@ -16,15 +16,26 @@ public enum MessageType: UInt8 {
case leave = 0x03 // "I'm leaving"
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
case requestSync = 0x21 // GCS filter-based sync request (local-only)
-
+
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
-
+
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
-
+ case boardPost = 0x23 // Signed geohash bulletin-board post or tombstone
+ case prekeyBundle = 0x24 // Signed batch of one-time prekeys (gossiped)
+ case groupMessage = 0x25 // Group-encrypted broadcast (cleartext group ID, ChaChaPoly body)
+
+ // Mesh diagnostics
+ case ping = 0x26 // Directed echo request (nonce + origin TTL)
+ case pong = 0x27 // Directed echo reply (echoed nonce + origin TTL)
+
+ // Gateway mode: signed Nostr event ferried between a mesh-only peer and
+ // an internet gateway peer.
+ case nostrCarrier = 0x28
+
public var description: String {
switch self {
case .announce: return "announce"
@@ -36,6 +47,12 @@ public enum MessageType: UInt8 {
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
+ case .boardPost: return "boardPost"
+ case .prekeyBundle: return "prekeyBundle"
+ case .groupMessage: return "groupMessage"
+ case .ping: return "ping"
+ case .pong: return "pong"
+ case .nostrCarrier: return "nostrCarrier"
}
}
}
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift
new file mode 100644
index 00000000..94858d9b
--- /dev/null
+++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerCapabilities.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+/// Feature capabilities a peer advertises in its announce packet.
+///
+/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the
+/// wire form grows only when high bits are assigned. Decoders keep the low 64
+/// bits and ignore any longer field, and unknown bits are preserved verbatim —
+/// old clients skip the TLV entirely, new clients degrade per-feature.
+public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
+ public let rawValue: UInt64
+
+ public init(rawValue: UInt64) {
+ self.rawValue = rawValue
+ }
+
+ public static let prekeys = PeerCapabilities(rawValue: 1 << 0)
+ public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1)
+ public static let gateway = PeerCapabilities(rawValue: 1 << 2)
+ public static let groups = PeerCapabilities(rawValue: 1 << 3)
+ public static let board = PeerCapabilities(rawValue: 1 << 4)
+ public static let vouch = PeerCapabilities(rawValue: 1 << 5)
+ public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6)
+
+ /// Minimal little-endian byte encoding; always at least one byte so an
+ /// empty set is distinguishable from an absent TLV.
+ public func encoded() -> Data {
+ var value = rawValue
+ var bytes = Data()
+ repeat {
+ bytes.append(UInt8(truncatingIfNeeded: value))
+ value >>= 8
+ } while value != 0
+ return bytes
+ }
+
+ /// Accepts any length; bytes beyond the low 64 bits are ignored for
+ /// forward compatibility.
+ public init(encoded data: Data) {
+ var value: UInt64 = 0
+ for (index, byte) in data.prefix(8).enumerated() {
+ value |= UInt64(byte) << (8 * index)
+ }
+ self.init(rawValue: value)
+ }
+}
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift
index 0b2721c2..f22fac18 100644
--- a/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift
+++ b/localPackages/BitFoundation/Sources/BitFoundation/PeerID.swift
@@ -34,6 +34,9 @@ public struct PeerID: Equatable, Hashable, Sendable {
case geoDM = "nostr_"
/// `"nostr:"` (+ 8 characters hex)
case geoChat = "nostr:"
+ /// `"group_"` (+ 32 characters hex) — virtual conversation ID for a
+ /// private group (16-byte group ID). Never routed to a single peer.
+ case group = "group_"
}
public let prefix: Prefix
@@ -96,6 +99,22 @@ public extension PeerID {
}
}
+// MARK: - Group Conversation Helpers
+
+public extension PeerID {
+ /// Convenience init to create a virtual group conversation PeerID from a
+ /// 16-byte group ID ("group_" + 32 hex characters).
+ init(groupID: Data) {
+ self.init(str: Prefix.group.rawValue + groupID.hexEncodedString())
+ }
+
+ /// The 16-byte group ID behind a "group_" PeerID, if this is one.
+ var groupIDData: Data? {
+ guard isGroup, bare.count == 32 else { return nil }
+ return Data(hexString: bare)
+ }
+}
+
// MARK: - Noise Public Key Helpers
public extension PeerID {
@@ -143,6 +162,11 @@ public extension PeerID {
prefix == .geoDM
}
+ /// Returns true if `id` starts with "`group_`"
+ var isGroup: Bool {
+ prefix == .group
+ }
+
func toPercentEncoded() -> String {
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
}
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift
new file mode 100644
index 00000000..0e73a2c0
--- /dev/null
+++ b/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift
@@ -0,0 +1,195 @@
+//
+// PrekeyBundle.swift
+// BitFoundation
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Foundation
+
+/// TLV payload for gossiped one-time prekey bundles (MessageType 0x24).
+///
+/// A bundle publishes a batch of one-time Curve25519 public prekeys bound to
+/// the owner's Noise static key by an Ed25519 signature over domain-prefixed
+/// canonical bytes. Anyone holding the owner's announce-verified signing key
+/// can verify a bundle offline, which is what lets bundles spread and persist
+/// mesh-wide via gossip sync while the owner is away. Senders seal courier
+/// mail to one of these prekeys (one-way Noise X) instead of the owner's
+/// long-lived static key, restoring forward secrecy for async first contact.
+public struct PrekeyBundle: Equatable {
+ public struct Prekey: Equatable {
+ public let id: UInt32
+ /// Curve25519.KeyAgreement public key (32 bytes).
+ public let publicKey: Data
+
+ public init(id: UInt32, publicKey: Data) {
+ self.id = id
+ self.publicKey = publicKey
+ }
+ }
+
+ /// Noise static public key identifying whose prekeys these are (32 bytes).
+ public let noiseStaticPublicKey: Data
+ /// One-time prekeys, at most `maxPrekeys` per bundle.
+ public let prekeys: [Prekey]
+ /// Milliseconds since epoch when this bundle was generated; newer bundles
+ /// replace older ones for the same noise key.
+ public let generatedAt: UInt64
+ /// Ed25519 signature over `signableBytes()` by the owner's announce-bound
+ /// signing key.
+ public let signature: Data
+
+ public static let keyLength = 32
+ public static let signatureLength = 64
+ public static let maxPrekeys = 8
+ private static let prekeyEntryLength = 4 + keyLength
+
+ /// Domain separation for the bundle signature so it can never be confused
+ /// with announce or packet signatures.
+ private static let signingContext = Data("bitchat-prekey-bundle-v1".utf8)
+
+ private enum TLVType: UInt8 {
+ case noiseStaticPublicKey = 0x01
+ case prekeys = 0x02
+ case generatedAt = 0x03
+ case signature = 0x04
+ }
+
+ public init(noiseStaticPublicKey: Data, prekeys: [Prekey], generatedAt: UInt64, signature: Data) {
+ self.noiseStaticPublicKey = noiseStaticPublicKey
+ self.prekeys = prekeys
+ self.generatedAt = generatedAt
+ self.signature = signature
+ }
+
+ /// Canonical bytes covered by the Ed25519 signature: domain context,
+ /// owner key, prekey count, each (id, key) pair, and the generation time.
+ /// Encoders and verifiers must derive these identically.
+ public func signableBytes() -> Data {
+ var out = Data()
+ out.reserveCapacity(1 + Self.signingContext.count + Self.keyLength + 1
+ + prekeys.count * Self.prekeyEntryLength + 8)
+ out.append(UInt8(min(Self.signingContext.count, 255)))
+ out.append(Self.signingContext.prefix(255))
+ out.append(paddedKey(noiseStaticPublicKey))
+ out.append(UInt8(min(prekeys.count, 255)))
+ for prekey in prekeys.prefix(255) {
+ appendBE(prekey.id, into: &out)
+ out.append(paddedKey(prekey.publicKey))
+ }
+ appendBE(generatedAt, into: &out)
+ return out
+ }
+
+ public func encode() -> Data? {
+ guard noiseStaticPublicKey.count == Self.keyLength,
+ signature.count == Self.signatureLength,
+ !prekeys.isEmpty, prekeys.count <= Self.maxPrekeys,
+ prekeys.allSatisfy({ $0.publicKey.count == Self.keyLength }) else {
+ return nil
+ }
+
+ var entries = Data()
+ entries.reserveCapacity(prekeys.count * Self.prekeyEntryLength)
+ for prekey in prekeys {
+ appendBE(prekey.id, into: &entries)
+ entries.append(prekey.publicKey)
+ }
+
+ var encoded = Data()
+ encoded.reserveCapacity(4 * 3 + Self.keyLength + entries.count + 8 + Self.signatureLength)
+
+ encoded.append(TLVType.noiseStaticPublicKey.rawValue)
+ appendBE(UInt16(noiseStaticPublicKey.count), into: &encoded)
+ encoded.append(noiseStaticPublicKey)
+
+ encoded.append(TLVType.prekeys.rawValue)
+ appendBE(UInt16(entries.count), into: &encoded)
+ encoded.append(entries)
+
+ encoded.append(TLVType.generatedAt.rawValue)
+ appendBE(UInt16(8), into: &encoded)
+ appendBE(generatedAt, into: &encoded)
+
+ encoded.append(TLVType.signature.rawValue)
+ appendBE(UInt16(signature.count), into: &encoded)
+ encoded.append(signature)
+
+ return encoded
+ }
+
+ public static func decode(_ data: Data) -> PrekeyBundle? {
+ var cursor = data.startIndex
+ let end = data.endIndex
+
+ var noiseStaticPublicKey: Data?
+ var prekeys: [Prekey]?
+ var generatedAt: UInt64?
+ var signature: Data?
+
+ while cursor < end {
+ let typeRaw = data[cursor]
+ cursor = data.index(after: cursor)
+
+ guard data.distance(from: cursor, to: end) >= 2 else { return nil }
+ let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
+ cursor = data.index(cursor, offsetBy: 2)
+ guard data.distance(from: cursor, to: end) >= length else { return nil }
+ let value = data[cursor.. 0, length % prekeyEntryLength == 0,
+ length / prekeyEntryLength <= maxPrekeys else { return nil }
+ var parsed: [Prekey] = []
+ var entryStart = value.startIndex
+ while entryStart < value.endIndex {
+ let idEnd = value.index(entryStart, offsetBy: 4)
+ let id = value[entryStart.. Data {
+ let fixed = key.prefix(Self.keyLength)
+ guard fixed.count < Self.keyLength else { return Data(fixed) }
+ return Data(fixed) + Data(repeating: 0, count: Self.keyLength - fixed.count)
+ }
+}
+
+private func appendBE(_ value: T, into data: inout Data) {
+ var big = value.bigEndian
+ withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
+}
diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift
index a64551ab..9631ee81 100644
--- a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift
+++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift
@@ -20,6 +20,38 @@ struct CourierEnvelopeTests {
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
}
+ // MARK: - Spray copies
+
+ @Test func copiesRoundTrip() throws {
+ let envelope = makeEnvelope().withCopies(4)
+ let encoded = try #require(envelope.encode())
+ let decoded = try #require(CourierEnvelope.decode(encoded))
+ #expect(decoded.copies == 4)
+ #expect(decoded == envelope)
+ }
+
+ @Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws {
+ // copies == 1 must be byte-identical to the pre-spray wire format so
+ // old and new clients dedup the same envelope the same way.
+ let envelope = makeEnvelope()
+ #expect(envelope.copies == 1)
+ let encoded = try #require(envelope.encode())
+ let withExplicitOne = try #require(envelope.withCopies(1).encode())
+ #expect(encoded == withExplicitOne)
+ #expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1)
+ }
+
+ @Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws {
+ let encoded = try #require(makeEnvelope().encode())
+ let decoded = try #require(CourierEnvelope.decode(encoded))
+ #expect(decoded.copies == 1)
+ }
+
+ @Test func copiesAreClampedToPolicyBounds() {
+ #expect(makeEnvelope().withCopies(0).copies == 1)
+ #expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies)
+ }
+
// MARK: - Codec
@Test func roundTrip() throws {
diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift
new file mode 100644
index 00000000..8032bd9c
--- /dev/null
+++ b/localPackages/BitFoundation/Tests/BitFoundationTests/MeshPingPayloadTests.swift
@@ -0,0 +1,70 @@
+//
+// MeshPingPayloadTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+@testable import BitFoundation
+
+struct MeshPingPayloadTests {
+
+ @Test func encodeDecodeRoundTrip() throws {
+ let nonce = Data([0x01, 0x02, 0x03, 0x04, 0xAA, 0xBB, 0xCC, 0xFF])
+ let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 7))
+
+ let encoded = payload.encode()
+ #expect(encoded.count == 9)
+ #expect(encoded.prefix(8) == nonce)
+ #expect(encoded.last == 7)
+
+ let decoded = try #require(MeshPingPayload.decode(encoded))
+ #expect(decoded == payload)
+ }
+
+ @Test func decodeToleratesTrailingBytes() throws {
+ let nonce = Data(repeating: 0x42, count: 8)
+ let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 3))
+ var extended = payload.encode()
+ extended.append(contentsOf: [0xDE, 0xAD])
+
+ let decoded = try #require(MeshPingPayload.decode(extended))
+ #expect(decoded == payload)
+ }
+
+ @Test func decodeRespectsSliceIndices() throws {
+ // Data slices keep their parent's indices; decoding must not assume
+ // startIndex == 0.
+ let nonce = Data(repeating: 0x11, count: 8)
+ let payload = try #require(MeshPingPayload(nonce: nonce, originTTL: 5))
+ let framed = Data([0x00, 0x00]) + payload.encode()
+ let slice = framed.dropFirst(2)
+
+ let decoded = try #require(MeshPingPayload.decode(slice))
+ #expect(decoded == payload)
+ }
+
+ @Test func rejectsTruncatedPayload() {
+ #expect(MeshPingPayload.decode(Data(repeating: 0x01, count: 8)) == nil)
+ #expect(MeshPingPayload.decode(Data()) == nil)
+ }
+
+ @Test func rejectsWrongNonceLength() {
+ #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 7), originTTL: 7) == nil)
+ #expect(MeshPingPayload(nonce: Data(repeating: 0, count: 9), originTTL: 7) == nil)
+ }
+
+ @Test func hopCountMath() {
+ // Direct link: no TTL decrement, one hop.
+ #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 7) == 1)
+ // One relay in between: two hops.
+ #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 6) == 2)
+ // Full TTL consumed.
+ #expect(MeshPingPayload.hopCount(originTTL: 7, receivedTTL: 1) == 7)
+ // Inconsistent TTLs (received above origin) are rejected.
+ #expect(MeshPingPayload.hopCount(originTTL: 3, receivedTTL: 7) == nil)
+ }
+}
diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift
new file mode 100644
index 00000000..1c83530f
--- /dev/null
+++ b/localPackages/BitFoundation/Tests/BitFoundationTests/PeerCapabilitiesTests.swift
@@ -0,0 +1,43 @@
+//
+// PeerCapabilitiesTests.swift
+// bitchatTests
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import Testing
+import Foundation
+@testable import BitFoundation
+
+struct PeerCapabilitiesTests {
+ @Test
+ func encodingIsMinimalAndRoundTrips() {
+ #expect(PeerCapabilities([]).encoded() == Data([0x00]))
+ #expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
+ #expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
+
+ let high = PeerCapabilities(rawValue: 1 << 9)
+ #expect(high.encoded() == Data([0x00, 0x02]))
+
+ let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
+ #expect(PeerCapabilities(encoded: all.encoded()) == all)
+ #expect(PeerCapabilities(encoded: high.encoded()) == high)
+ #expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
+ }
+
+ @Test
+ func decodingToleratesUnknownBitsAndOversizedFields() {
+ // Unknown bits survive a round-trip untouched.
+ let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF]))
+ #expect(unknown.rawValue == 0xFFFF)
+ #expect(unknown.contains(.gateway))
+
+ // Fields longer than 8 bytes keep the low 64 bits and ignore the rest.
+ let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB])
+ #expect(PeerCapabilities(encoded: oversized) == .prekeys)
+
+ // Empty value decodes to no capabilities.
+ #expect(PeerCapabilities(encoded: Data()) == [])
+ }
+}