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
+2 -1
View File
@@ -125,7 +125,8 @@ struct BLEPeerRegistry {
nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen
lastSeen: info.lastSeen,
isVerified: info.isVerifiedNickname
)
}
}
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
}
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
// Acceptance window matches the gossip-sync serving window: a peer
// walking between partitions carries hours of public history, so the
// receive side must not drop what sync legitimately serves.
if isBroadcast,
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
BLEPacketFreshnessPolicy.isStale(
timestampMilliseconds: packet.timestamp,
now: now,
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
) {
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
@@ -48,7 +48,11 @@ struct BLEReceivePipeline {
senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
// Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
+110 -23
View File
@@ -48,12 +48,17 @@ final class BLEService: NSObject {
private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. Injectable
// for tests; main-actor policy because favorites live on the main actor.
// third parties, and the trust gate for accepting deposits. The policy
// maps (depositor key, announce-verified?) to a quota tier, or nil to
// reject. Injectable for tests; main-actor policy because favorites live
// on the main actor.
var courierStore: CourierStore = .shared
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
return isVerifiedPeer ? .verified : nil
}
// Local-only store-and-forward counters; nil in unit tests.
var sfMetrics: StoreAndForwardMetrics?
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
@@ -275,6 +280,7 @@ final class BLEService: NSObject {
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
@@ -286,8 +292,10 @@ final class BLEService: NSObject {
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
)
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
manager.delegate = self
// Only start the periodic sync timers when real Bluetooth exists. In unit
// tests there is no mesh to sync with, and the periodic sign/broadcast
@@ -2515,7 +2523,8 @@ extension BLEService {
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed
ciphertext: sealed,
copies: TransportConfig.courierInitialCopies
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
@@ -2535,7 +2544,7 @@ extension BLEService {
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
BitchatPacket(
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -2544,6 +2553,10 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
// Signed so a courier can authenticate the depositor before carrying
// mail under their quota. Handover to the recipient doesn't need the
// packet signature the inner Noise X seal authenticates the sender.
return noiseService.signPacket(packet) ?? packet
}
/// Handles both courier roles for an incoming envelope addressed to us:
@@ -2559,7 +2572,7 @@ extension BLEService {
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope)
} else {
acceptCourierDeposit(envelope, from: peerID)
acceptCourierDeposit(envelope, from: peerID, packet: packet)
}
}
@@ -2589,6 +2602,7 @@ extension BLEService {
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session)
sfMetrics?.record(.courierOpened)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
@@ -2603,20 +2617,38 @@ extension BLEService {
}
}
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
// A deposit must come from its depositor over the direct link: the
// claimed sender has to be the ingress peer, and the packet signature
// has to verify against that peer's announced signing key. Otherwise
// an untrusted sender could route an envelope through any trusted
// neighbor and have us carry it under the neighbor's quota.
guard PeerID(hexData: packet.senderID) == peerID else {
SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))", category: .security)
return
}
let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
guard let depositorKey = depositorInfo?.noisePublicKey else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
guard let signingKey = depositorInfo?.signingPublicKey,
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
return
}
let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
let store = courierStore
let policy = courierDepositPolicy
let metrics = sfMetrics
Task { @MainActor in
guard policy(depositorKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
guard let tier = policy(depositorKey, isVerifiedPeer) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
return
}
if store.deposit(envelope, from: depositorKey) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))", category: .session)
if store.deposit(envelope, from: depositorKey, tier: tier) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8)) (\(tier.rawValue))", category: .session)
metrics?.record(.courierAccepted)
}
}
}
@@ -2629,6 +2661,51 @@ extension BLEService {
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
sfMetrics?.record(.courierHandedOver)
}
}
/// Speculative handover toward a recipient heard only via a relayed
/// announce: the envelope floods the mesh as a directed packet (relays
/// treat it like a directed DM). Non-destructive the carried copy stays
/// until a direct handover or expiry, throttled per envelope so repeated
/// announces don't re-flood.
private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.envelopesForRemoteHandover(
recipientNoiseKey: noiseKey,
cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
broadcastPacket(makeCourierPacket(payload, to: peerID))
sfMetrics?.record(.courierRemoteHandover)
}
}
/// Spray-and-wait: split copy budgets with another courier we just
/// encountered, so carried mail diffuses through a moving crowd instead
/// of riding a single carrier. Only favorites and verified peers qualify,
/// mirroring the deposit policy they would apply to us.
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
let store = courierStore
let metrics = sfMetrics
let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
guard let self, !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
metrics?.record(.courierSprayed)
}
}
let policy = courierDepositPolicy
Task { @MainActor in
// Same trust gate as deposits: don't hand mail to a peer who
// would reject it from us.
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
sendSpray(store.takeSprayCopies(for: noiseKey))
}
}
@@ -2755,6 +2832,9 @@ extension BLEService {
centralManager?.stopScan()
startScanning()
}
// Backgrounding may precede a kill; flush the public-history archive
// outside its 30s maintenance cadence.
gossipSyncManager?.persistNow()
logBluetoothStatus("entered-background")
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
// No Local Name; nothing to refresh for advertising policy
@@ -3186,16 +3266,23 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// Courier handover: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them.
// Direct announces only: envelopes are removed from the store
// optimistically, so handover must ride an established link rather
// than a speculative multi-hop send toward a relayed announce.
// Courier work: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them
// (or spray-able mail they could carry). Verified announces only.
guard !courierStore.isEmpty,
let result,
result.isVerified,
result.isDirectAnnounce else { return }
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
result.isVerified else { return }
let noiseKey = result.announcement.noisePublicKey
if result.isDirectAnnounce {
// Established link: destructive handover is safe, and the peer is
// close enough to become a courier for other carried mail.
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
} else {
// Relayed announce: recipient is multi-hop away. Push a copy
// toward them speculatively; the carried copy stays put.
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
}
}
/// Builds the announce handler environment. All queue hops stay here so
+152 -19
View File
@@ -11,13 +11,22 @@ import BitLogger
import Combine
import Foundation
/// Trust level of a courier deposit, decided by the caller's policy.
/// Favorites get the larger quota and are never evicted to make room for
/// verified-tier mail; verified (signature-verified announce, not a mutual
/// favorite) get a small quota so a crowd of strangers can still carry mail.
enum CourierDepositTier: String, Codable {
case favorite
case verified
}
/// Holds courier envelopes this device is carrying for offline third parties.
///
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
/// never learns sender, recipient, or content. Strict quotas keep the device
/// from becoming a public mailbag: bounded count, bounded per-depositor
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
/// retention policy. Carried mail is included in the panic wipe.
/// Envelopes are opaque ciphertext; this store never learns sender,
/// recipient, or content. Strict quotas keep the device from becoming a
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
/// policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
@@ -25,15 +34,63 @@ final class CourierStore {
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
var tier: CourierDepositTier
/// Remaining spray-and-wait budget (1 = carry-only).
var copies: UInt8
/// Couriers this envelope was already sprayed to, so a repeat announce
/// from the same peer doesn't burn budget on a copy they already hold.
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
}
init(
recipientTag: Data,
expiry: UInt64,
ciphertext: Data,
depositorNoiseKey: Data,
storedAt: Date,
tier: CourierDepositTier,
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil
) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
self.depositorNoiseKey = depositorNoiseKey
self.storedAt = storedAt
self.tier = tier
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
}
// Files written before tiers/spray lack the newer fields; treat that
// mail as favorite-tier carry-only, which is what it was.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
recipientTag = try container.decode(Data.self, forKey: .recipientTag)
expiry = try container.decode(UInt64.self, forKey: .expiry)
ciphertext = try container.decode(Data.self, forKey: .ciphertext)
depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
storedAt = try container.decode(Date.self, forKey: .storedAt)
tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
}
}
enum Limits {
static let maxEnvelopes = 20
static let maxPerDepositor = 5
static let maxEnvelopes = 40
/// Verified-tier mail can never crowd out favorites' share.
static let maxVerifiedEnvelopes = 20
static let maxPerFavoriteDepositor = 5
static let maxPerVerifiedDepositor = 2
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
@@ -65,10 +122,11 @@ final class CourierStore {
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (mutual favorite) is the
/// caller's responsibility; this store only enforces resource bounds.
/// validity checks reject it. Trust policy (which tier a depositor gets,
/// if any) is the caller's responsibility; this store only enforces
/// resource bounds.
@discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
@@ -86,18 +144,39 @@ final class CourierStore {
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently.
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
persistLocked()
return true
}
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
return false
}
if tier == .verified,
envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, matching outbox overflow behavior.
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
// Oldest-first eviction, shedding verified-tier mail before
// favorites' so open couriering can't crowd out trusted mail.
// A verified deposit never displaces a favorite: when only
// favorite mail is stored, it is rejected instead.
if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
let evicted = envelopes.remove(at: victim)
SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
} else if tier == .favorite {
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
} else {
SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
return false
}
}
envelopes.append(StoredEnvelope(
@@ -105,7 +184,9 @@ final class CourierStore {
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
storedAt: date
storedAt: date,
tier: tier,
copies: envelope.copies
))
persistLocked()
return true
@@ -131,6 +212,58 @@ final class CourierStore {
}
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
/// announce. Non-destructive: a multi-hop send is speculative, so the
/// envelope stays carried until a direct handover or expiry. The per-
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
if let last = envelopes[index].lastRemoteHandoverAt,
date.timeIntervalSince(last) < cooldown {
continue
}
envelopes[index].lastRemoteHandoverAt = date
// The delivered copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
/// half its remaining budget (binary spray). Skips envelopes the courier
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue }
let given = stored.copies / 2
envelopes[index].copies = stored.copies - given
envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given))
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
}
// MARK: - Maintenance
func pruneExpired() {
@@ -0,0 +1,156 @@
//
// MessageOutboxStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
import Security
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
///
/// Nothing else in the app persists message plaintext, and this store keeps
/// that property: the outbox is sealed with a ChaChaPoly key that lives only
/// in the Keychain (after-first-unlock, this device only), on top of iOS file
/// protection. Wiped on panic alongside the courier store.
final class MessageOutboxStore {
struct QueuedMessage: Codable, Equatable {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
var sendAttempts: Int
/// Noise keys of couriers already carrying this message, so deposit
/// retries add couriers instead of re-burning the same ones.
var depositedCourierKeys: Set<Data>
init(
content: String,
nickname: String,
messageID: String,
timestamp: Date,
sendAttempts: Int = 0,
depositedCourierKeys: Set<Data> = []
) {
self.content = content
self.nickname = nickname
self.messageID = messageID
self.timestamp = timestamp
self.sendAttempts = sendAttempts
self.depositedCourierKeys = depositedCourierKeys
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
content = try container.decode(String.self, forKey: .content)
nickname = try container.decode(String.self, forKey: .nickname)
messageID = try container.decode(String.self, forKey: .messageID)
timestamp = try container.decode(Date.self, forKey: .timestamp)
sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
depositedCourierKeys = try container.decodeIfPresent(Set<Data>.self, forKey: .depositedCourierKeys) ?? []
}
}
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
}
// MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] {
guard let fileURL,
let sealed = try? Data(contentsOf: fileURL),
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
return outbox
}
func save(_ outbox: [PeerID: [QueuedMessage]]) {
guard let fileURL else { return }
let flattened = outbox.filter { !$0.value.isEmpty }
guard !flattened.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
}
do {
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try sealed.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
}
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
}
// MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
return SymmetricKey(data: data)
}
guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes.
keychain.save(
key: Self.keychainKey,
data: data,
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
return key
}
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("courier", isDirectory: true)
.appendingPathComponent("outbox.sealed")
}
}
@@ -0,0 +1,74 @@
//
// StoreAndForwardMetrics.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Privacy-safe local counters for the store-and-forward stack: bare event
/// tallies with no message IDs, peer identities, or timestamps, so delivery
/// behavior can be measured on-device without recording who talked to whom.
/// Log-only surface nothing here ever leaves the device.
final class StoreAndForwardMetrics {
enum Event: String, CaseIterable {
/// A private message entered the outbox (no prompt route available).
case outboxQueued = "outbox.queued"
/// A retained message was re-sent on a flush.
case outboxResent = "outbox.resent"
/// A delivery/read ack cleared a retained message.
case outboxDelivered = "outbox.delivered"
/// A retained message was dropped (attempt cap, TTL, or overflow).
case outboxDropped = "outbox.dropped"
/// We handed sealed mail to a courier.
case courierDeposited = "courier.deposited"
/// We accepted sealed mail to carry for a third party.
case courierAccepted = "courier.accepted"
/// We handed carried mail to its recipient over a direct link.
case courierHandedOver = "courier.handedOver"
/// We pushed carried mail toward a recipient heard via relay.
case courierRemoteHandover = "courier.remoteHandover"
/// We split spray copies to another courier.
case courierSprayed = "courier.sprayed"
/// Couriered mail addressed to us was opened and delivered.
case courierOpened = "courier.opened"
}
static let shared = StoreAndForwardMetrics()
private let lock = NSLock()
private var counts: [String: Int]
private let defaults: UserDefaults
private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
}
func record(_ event: Event) {
lock.lock()
let total = (counts[event.rawValue] ?? 0) + 1
counts[event.rawValue] = total
defaults.set(counts, forKey: Self.defaultsKey)
lock.unlock()
SecureLogger.debug("📊 S&F \(event.rawValue)\(total)", category: .session)
}
func snapshot() -> [String: Int] {
lock.lock()
defer { lock.unlock() }
return counts
}
/// Included in the panic wipe alongside the stores it describes.
func reset() {
lock.lock()
counts = [:]
defaults.removeObject(forKey: Self.defaultsKey)
lock.unlock()
}
}
+149 -37
View File
@@ -7,7 +7,9 @@ import Foundation
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) may carry our mail.
/// Whether a peer (by Noise static key) is a mutual favorite the
/// preferred courier tier. Verified non-favorites are the fallback tier,
/// read off the transport snapshot.
var isTrustedCourier: (Data) -> Bool
@MainActor
@@ -30,9 +32,13 @@ struct CourierDirectory {
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
private let transports: [Transport]
private let now: () -> Date
private let courierDirectory: CourierDirectory
private let outboxStore: MessageOutboxStore?
private let metrics: StoreAndForwardMetrics?
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -41,20 +47,11 @@ final class MessageRouter {
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected mutual favorite who will physically
/// carry the sealed envelope). Delivery stays best-effort: the outbox
/// retains the message until an ack arrives.
/// least one courier (a connected peer who will physically carry the
/// sealed envelope). Delivery stays best-effort: the outbox retains the
/// message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
var sendAttempts: Int = 0
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
@@ -69,11 +66,16 @@ final class MessageRouter {
init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil
courierDirectory: CourierDirectory? = nil,
outboxStore: MessageOutboxStore? = nil,
metrics: StoreAndForwardMetrics? = nil
) {
self.transports = transports
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
self.outboxStore = outboxStore
self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:]
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -134,50 +136,137 @@ final class MessageRouter {
// may never come. Double delivery is harmless receivers dedup
// by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) {
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
attemptCourierDeposit(messageID: messageID, for: peerID)
}
} else {
var unsent = message
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
attemptCourierDeposit(messageID: messageID, for: peerID)
}
}
// MARK: - Couriers
/// Last resort when no transport can deliver promptly the peer is
/// unreachable, or only reachable through a send queue waiting on
/// internet: seal the message to their known static key and hand it to
/// connected mutual favorites who may physically encounter them. The
/// queued copy above stays retained, so direct delivery still wins if
/// the peer reappears first (receivers dedup by message ID).
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
/// connected couriers who may physically encounter them. Mutual favorites
/// are preferred; signature-verified strangers fill remaining slots so a
/// crowd without favorites can still carry mail (envelopes are opaque
/// either way). The queued copy stays retained, so direct delivery still
/// wins if the peer reappears first (receivers dedup by message ID).
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID),
let entry = queuedMessage(messageID, for: peerID) else { return }
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
for transport in transports {
let couriers = transport.currentPeerSnapshots()
.filter { snapshot in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey else { return false }
return courierDirectory.isTrustedCourier(key)
}
.prefix(Self.maxCouriersPerMessage)
.map(\.peerID)
let couriers = eligibleCouriers(
on: transport,
recipientKey: recipientKey,
excluding: entry.depositedCourierKeys,
limit: remainingSlots
)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
return
}
}
}
/// A courier candidate just connected: hand them any queued mail they are
/// not already carrying. This is what turns couriering from "a favorite
/// happened to be around at send time" into eventual spread deposits
/// retry as eligible peers appear, until each message rides with
/// `maxCouriersPerMessage` distinct couriers or expires.
func courierBecameAvailable(_ peerID: PeerID) {
for transport in transports {
guard transport.isPeerConnected(peerID),
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
let courierKey = snapshot.noisePublicKey,
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
let currentDate = now()
for (recipient, queue) in outbox {
// Mail *to* this peer flushes directly on connect.
guard recipient != peerID,
let recipientKey = courierDirectory.noiseKey(recipient),
recipientKey != courierKey else { continue }
for message in queue {
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
!message.depositedCourierKeys.contains(courierKey),
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
onMessageCarried?(message.messageID, recipient)
}
}
}
return
}
}
private struct CourierCandidate {
let peerID: PeerID
let noiseKey: Data
}
private func eligibleCouriers(
on transport: Transport,
recipientKey: Data,
excluding excludedKeys: Set<Data>,
limit: Int
) -> [CourierCandidate] {
guard limit > 0 else { return [] }
let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey,
!excludedKeys.contains(key) else { return nil }
let isFavorite = courierDirectory.isTrustedCourier(key)
guard isFavorite || snapshot.isVerified else { return nil }
return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
}
return candidates
.sorted { $0.isFavorite && !$1.isFavorite }
.prefix(limit)
.map(\.0)
}
private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
outbox[peerID]?.first { $0.messageID == messageID }
}
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited)
guard var queue = outbox[peerID],
let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
queue[index].depositedCourierKeys.formUnion(courierKeys)
outbox[peerID] = queue
persistOutbox()
}
// MARK: - Outbox Management
/// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) {
var cleared = false
for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true
}
if cleared {
metrics?.record(.outboxDelivered)
persistOutbox()
}
}
@@ -191,9 +280,26 @@ final class MessageRouter {
if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session)
onMessageDropped?(evicted.messageID, peerID)
dropMessage(evicted.messageID, for: peerID)
}
outbox[peerID] = queue
metrics?.record(.outboxQueued)
persistOutbox()
}
private func dropMessage(_ messageID: String, for peerID: PeerID) {
metrics?.record(.outboxDropped)
onMessageDropped?(messageID, peerID)
}
private func persistOutbox() {
outboxStore?.save(outbox)
}
/// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() {
outbox.removeAll()
outboxStore?.wipe()
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
@@ -220,8 +326,6 @@ final class MessageRouter {
}
}
// MARK: - Outbox Management
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
@@ -233,7 +337,7 @@ final class MessageRouter {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
onMessageDropped?(message.messageID, peerID)
dropMessage(message.messageID, for: peerID)
continue
}
@@ -241,16 +345,18 @@ final class MessageRouter {
// Live link: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
} else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
onMessageDropped?(message.messageID, peerID)
dropMessage(message.messageID, for: peerID)
continue
}
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
var retained = message
retained.sendAttempts += 1
remaining.append(retained)
@@ -264,6 +370,7 @@ final class MessageRouter {
} else {
outbox[peerID] = remaining
}
persistOutbox()
}
func flushAllOutbox() {
@@ -273,6 +380,7 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = now()
var droppedAny = false
for peerID in Array(outbox.keys) {
var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in
@@ -285,8 +393,12 @@ final class MessageRouter {
}
for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
onMessageDropped?(messageID, peerID)
dropMessage(messageID, for: peerID)
droppedAny = true
}
}
if droppedAny {
persistOutbox()
}
}
}
+18
View File
@@ -11,6 +11,24 @@ 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
}
}
enum TransportEvent: @unchecked Sendable {
+16
View File
@@ -262,7 +262,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
@@ -273,4 +280,13 @@ enum TransportConfig {
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
}