mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
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>
352 lines
15 KiB
Swift
352 lines
15 KiB
Swift
//
|
|
// CourierStoreTests.swift
|
|
// bitchat
|
|
//
|
|
// This is free and unencumbered software released into the public domain.
|
|
// For more information, see <https://unlicense.org>
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
struct CourierStoreTests {
|
|
|
|
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
|
|
|
|
private func makeStore(now: Date = baseDate) -> CourierStore {
|
|
CourierStore(persistsToDisk: false, now: { now })
|
|
}
|
|
|
|
/// Store whose clock can be advanced by tests.
|
|
private final class Clock {
|
|
var now: Date
|
|
init(_ now: Date) { self.now = now }
|
|
}
|
|
|
|
private func makeEnvelope(
|
|
recipientKey: Data = Data(repeating: 0xB0, count: 32),
|
|
sealedAt: Date = baseDate,
|
|
lifetime: TimeInterval = 60 * 60,
|
|
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
|
|
) -> CourierEnvelope {
|
|
CourierEnvelope(
|
|
recipientTag: CourierEnvelope.recipientTag(
|
|
noiseStaticKey: recipientKey,
|
|
epochDay: CourierEnvelope.epochDay(for: sealedAt)
|
|
),
|
|
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
|
|
ciphertext: ciphertext
|
|
)
|
|
}
|
|
|
|
private let depositorA = Data(repeating: 0xA1, count: 32)
|
|
private let depositorB = Data(repeating: 0xA2, count: 32)
|
|
|
|
// MARK: - Deposit and handover
|
|
|
|
@Test func depositThenTakeForRecipient() {
|
|
let store = makeStore()
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
|
|
|
#expect(store.deposit(envelope, from: depositorA))
|
|
let taken = store.takeEnvelopes(for: recipientKey)
|
|
#expect(taken == [envelope])
|
|
// Handover removes the envelope.
|
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
|
}
|
|
|
|
@Test func takeIgnoresOtherRecipients() {
|
|
let store = makeStore()
|
|
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
|
|
store.deposit(envelope, from: depositorA)
|
|
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
|
|
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
|
}
|
|
|
|
@Test func duplicateDepositIsIdempotent() {
|
|
let store = makeStore()
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
|
#expect(store.deposit(envelope, from: depositorA))
|
|
#expect(store.deposit(envelope, from: depositorA))
|
|
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
|
}
|
|
|
|
// MARK: - Validity
|
|
|
|
@Test func rejectsExpiredAndOversizedAndMalformed() {
|
|
let store = makeStore()
|
|
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
|
|
#expect(!store.deposit(expired, from: depositorA))
|
|
|
|
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
|
|
#expect(!store.deposit(oversized, from: depositorA))
|
|
|
|
let badTag = CourierEnvelope(
|
|
recipientTag: Data(repeating: 0, count: 4),
|
|
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
|
|
ciphertext: Data(repeating: 1, count: 16)
|
|
)
|
|
#expect(!store.deposit(badTag, from: depositorA))
|
|
}
|
|
|
|
@Test func rejectsExpiryBeyondPolicyLifetime() {
|
|
let store = makeStore()
|
|
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
|
|
#expect(!store.deposit(pinned, from: depositorA))
|
|
}
|
|
|
|
// MARK: - Quotas
|
|
|
|
@Test func perDepositorQuota() {
|
|
let store = makeStore()
|
|
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor {
|
|
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
|
}
|
|
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
|
// A different depositor still has room.
|
|
#expect(store.deposit(makeEnvelope(), from: depositorB))
|
|
}
|
|
|
|
@Test func totalQuotaEvictsOldestFirst() {
|
|
let store = makeStore()
|
|
let firstRecipient = Data(repeating: 0xD0, count: 32)
|
|
let first = makeEnvelope(recipientKey: firstRecipient)
|
|
store.deposit(first, from: depositorA)
|
|
|
|
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
|
|
var deposited = 1
|
|
var depositorByte: UInt8 = 1
|
|
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
|
let depositor = Data(repeating: depositorByte, count: 32)
|
|
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
|
#expect(store.deposit(makeEnvelope(), from: depositor))
|
|
deposited += 1
|
|
}
|
|
depositorByte += 1
|
|
}
|
|
|
|
// The first envelope was evicted to make room.
|
|
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
|
|
}
|
|
|
|
// MARK: - Expiry over time
|
|
|
|
@Test func expiredEnvelopesAreNotHandedOver() {
|
|
let clock = Clock(Self.baseDate)
|
|
let store = CourierStore(persistsToDisk: false, now: { clock.now })
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
|
|
|
|
clock.now = Self.baseDate.addingTimeInterval(7200)
|
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
|
}
|
|
|
|
// MARK: - Panic wipe
|
|
|
|
@Test func wipeDropsEverything() {
|
|
let store = makeStore()
|
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
|
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
|
|
store.wipe()
|
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
@Test func persistsAndReloadsAcrossInstances() throws {
|
|
// Isolated on-disk location so the test never touches the real store.
|
|
let fileURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
|
|
.appendingPathComponent("envelopes.json")
|
|
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
|
|
|
|
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
|
|
|
let recipientKey = Data(repeating: 0xE0, count: 32)
|
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
|
#expect(first.deposit(envelope, from: depositorA))
|
|
|
|
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)
|
|
}
|
|
}
|