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
@@ -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))
}
+177 -2
View File
@@ -103,7 +103,7 @@ struct CourierStoreTests {
@Test func perDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerDepositor {
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA))
@@ -122,7 +122,7 @@ struct CourierStoreTests {
var depositorByte: UInt8 = 1
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
#expect(store.deposit(makeEnvelope(), from: depositor))
deposited += 1
}
@@ -173,4 +173,179 @@ struct CourierStoreTests {
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
}
// MARK: - Tiers (open couriering)
@Test func verifiedTierGetsSmallerPerDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerVerifiedDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA, tier: .verified))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA, tier: .verified))
// The same depositor promoted to favorite gets the larger quota.
#expect(store.deposit(makeEnvelope(), from: depositorB, tier: .favorite))
}
@Test func verifiedPoolIsCappedIndependentlyOfFavorites() {
let store = makeStore()
var depositorByte: UInt8 = 1
var accepted = 0
while accepted < CourierStore.Limits.maxVerifiedEnvelopes {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerVerifiedDepositor where accepted < CourierStore.Limits.maxVerifiedEnvelopes {
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .verified))
accepted += 1
}
depositorByte += 1
}
// Verified pool full: another verified deposit is rejected...
#expect(!store.deposit(makeEnvelope(), from: Data(repeating: 0xEE, count: 32), tier: .verified))
// ...but favorites still have their share.
#expect(store.deposit(makeEnvelope(), from: depositorA, tier: .favorite))
}
@Test func overflowEvictsVerifiedTierBeforeFavorites() {
let store = makeStore()
let favoriteRecipient = Data(repeating: 0xD0, count: 32)
let verifiedRecipient = Data(repeating: 0xD1, count: 32)
// Oldest envelope is a favorite deposit; a verified one follows.
#expect(store.deposit(makeEnvelope(recipientKey: favoriteRecipient), from: depositorA, tier: .favorite))
#expect(store.deposit(makeEnvelope(recipientKey: verifiedRecipient), from: depositorB, tier: .verified))
// Fill to the total cap with favorite deposits from distinct depositors.
var depositorByte: UInt8 = 10
var count = 2
while count < CourierStore.Limits.maxEnvelopes {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where count < CourierStore.Limits.maxEnvelopes {
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .favorite))
count += 1
}
depositorByte += 1
}
// The next favorite deposit evicts the verified envelope, not the
// older favorite one.
#expect(store.deposit(makeEnvelope(), from: Data(repeating: 0xEF, count: 32), tier: .favorite))
#expect(store.takeEnvelopes(for: verifiedRecipient).isEmpty)
#expect(store.takeEnvelopes(for: favoriteRecipient).count == 1)
}
@Test func verifiedDepositIsRejectedWhenStoreIsFullOfFavorites() {
let store = makeStore()
var depositorByte: UInt8 = 10
var count = 0
while count < CourierStore.Limits.maxEnvelopes {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where count < CourierStore.Limits.maxEnvelopes {
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .favorite))
count += 1
}
depositorByte += 1
}
// A verified deposit must not displace favorite-tier mail.
#expect(!store.deposit(makeEnvelope(), from: Data(repeating: 0xEE, count: 32), tier: .verified))
// A favorite deposit still can (oldest-favorite eviction).
#expect(store.deposit(makeEnvelope(), from: Data(repeating: 0xEF, count: 32), tier: .favorite))
}
// MARK: - Spray-and-wait
@Test func sprayHalvesBudgetAndSkipsIneligibleCouriers() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4)
#expect(store.deposit(envelope, from: depositorA))
// The recipient themselves never gets a spray copy (handover path).
#expect(store.takeSprayCopies(for: recipientKey).isEmpty)
// Neither does the depositor.
#expect(store.takeSprayCopies(for: depositorA).isEmpty)
// A fresh courier gets half the budget.
let courierX = Data(repeating: 0xC1, count: 32)
let sprayedToX = store.takeSprayCopies(for: courierX)
#expect(sprayedToX.count == 1)
#expect(sprayedToX.first?.copies == 2)
// Same courier again: no double spend.
#expect(store.takeSprayCopies(for: courierX).isEmpty)
// Next courier gets half the remainder (2 -> 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)
}
}
@@ -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())
+73
View File
@@ -468,6 +468,79 @@ struct GossipSyncManagerTests {
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
// 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 {
+7
View File
@@ -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,12 @@ 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: - Test Helpers
/// Clears all recorded method calls for fresh assertions
@@ -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)
@@ -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
@@ -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))
}
}
@@ -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<Data> = []) -> 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