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
@@ -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")
}
}