Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)

Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:

- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
  held only in the Keychain (no plaintext at rest); queued private
  messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
  courier connects, tracked per message so the same courier is never
  double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
  (2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
  (5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
  wire-compatible with old clients); couriers split half their remaining
  budget with each newly encountered courier so mail diffuses through a
  moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
  the multi-hop recipient (directed-relay treatment, 10-min per-envelope
  cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
  from 15 min to 6 h, matched on the receive-acceptance side, and the
  message store persists to disk so devices bridge partitions and
  restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
  store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
  counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
  actual flood control, courier system, gossip sync, Nostr path); the old
  document described a bloom filter, three fragment types, and a
  MessageRetryService that don't exist.

1037 macOS tests pass (17 new); iOS builds.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 19:33:16 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 295f855b6f
commit 7341696280
27 changed files with 1648 additions and 377 deletions
+67 -4
View File
@@ -64,7 +64,10 @@ 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
@@ -80,6 +83,7 @@ final class GossipSyncManager {
private let myPeerID: PeerID
private let config: Config
private let requestSyncManager: RequestSyncManager
private let archive: GossipMessageArchive?
weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender
@@ -87,6 +91,7 @@ final class GossipSyncManager {
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
private var archiveDirty = false
// Timer
private var periodicTimer: DispatchSourceTimer?
@@ -95,10 +100,11 @@ final class GossipSyncManager {
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
@@ -114,6 +120,12 @@ final class GossipSyncManager {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
if archive != nil {
queue.async { [weak self] in
self?.restoreArchivedMessages()
}
}
}
func start() {
@@ -153,10 +165,15 @@ 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 {
let maxAgeSeconds = packet.type == MessageType.message.rawValue
? config.publicMessageMaxAgeSeconds
: 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 }
@@ -197,6 +214,7 @@ final class GossipSyncManager {
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 }
@@ -417,14 +435,55 @@ final class GossipSyncManager {
isPacketFresh(pair.packet)
}
let messageCountBefore = messages.packets.count
messages.removeExpired(isFresh: isPacketFresh)
if messages.packets.count != messageCountBefore {
archiveDirty = true
}
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
}
// 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)
@@ -471,7 +530,11 @@ final class GossipSyncManager {
private func removeState(for peerID: PeerID) {
_ = 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 }
}