mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:25:21 +00:00
* DMs to unreachable peers route through store-and-forward; drop stale reachability gates
Field-found: sendPrivateMessage pre-judged reachability and marked the
message failed without ever calling the router, so the retained outbox,
courier deposits, and bridge drops never ran — a DM composed after a
peer's reachability window lapsed was dead on arrival while an identical
one sent a minute earlier delivered. Messages now always route; a live
path earns "sent", everything else stays "sending" until the router
reports carried/delivered or expires it as failed.
A sweep for sibling gates found and fixed:
- startPrivateChat refused offline non-mutual favorites ("mutual favorite
required for offline messaging") — store-and-forward only needs the
recipient's noise key, so the gate and its string are gone.
- markPrivateMessagesAsRead skipped the whole receipt pass for peers
without a stored Nostr key, starving mesh-connected non-favorites; the
router picks the transport now (sentReadReceipts dedups the parallel
PrivateChatManager path).
- DM/geoDM blocked notices and the unknown-group error posted to the
active public timeline; they now land in the thread they're about via
addLocalPrivateSystemMessage.
- Banned copy: literal "user" fallbacks in code become "anon" (the
default-nickname convention), and es/fr/it/pt translations of four keys
still saying usuario/utilisateur/utente/usuário now say person. Orphaned
keys removed (system.dm.unreachable, content.delivery.reason.unreachable,
system.chat.requires_favorite).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Deflake BLEServiceCoreTests: longTimeout for positive waits
CI flaked on duplicatePacket_isDeduped: the first message landed after the
5s wait expired on a loaded runner (the test's later count==1 assertions
passed, proving late delivery, not lost delivery). All four positive waits
in the file now use TestConstants.longTimeout, which exists for exactly
this — waitUntil returns as soon as the condition holds, so passing runs
never pay the longer ceiling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix read-receipt test contamination; baseline Periphery noiseKey flake
Two CI failures, both environmental:
- markReadReceiptSent_returnsFalseOnSecondCall flaked because every test
ChatViewModel shared one per-process read-receipts scratch suite; the
lifecycle test persists the same "read-1" ID (a path the receipt-gate
removal now reaches), and test order decided who saw whose state. Each
instance now gets its own UUID-suffixed suite.
- Periphery intermittently misses the loadFromDisk read of
PrekeyBundleStore.StoredBundle.noiseKey and fails --strict with a false
"assign-only" finding (recurrence of a known flake). Its USR is
baselined; an in-source periphery:ignore can't work because strict mode
flags it as superfluous on runs where the indexer gets it right.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
216 lines
8.3 KiB
Swift
216 lines
8.3 KiB
Swift
//
|
|
// PrekeyBundleStore.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 Foundation
|
|
|
|
/// Signature-verified one-time prekey bundles received from other peers.
|
|
///
|
|
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
|
|
/// copy, keeping the IDs we already sealed with marked used so a prekey is
|
|
/// never reused across messages. Assignments are remembered per message ID so
|
|
/// deposit retries of the same message re-use its prekey (and its budget)
|
|
/// instead of burning a fresh one per courier.
|
|
///
|
|
/// Only public key material lives here; it persists to disk so a sender can
|
|
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
|
final class PrekeyBundleStore {
|
|
struct StoredBundle: Codable {
|
|
// noiseKey is read in loadFromDisk (dictionary keying), but the
|
|
// Periphery indexer intermittently misses that read and flakes CI
|
|
// with "assign-only" — its USR is baselined (an in-source ignore
|
|
// can't work: strict mode flags it as superfluous on the runs where
|
|
// the indexer gets it right).
|
|
let noiseKey: Data
|
|
var generatedAt: UInt64
|
|
var prekeyIDs: [UInt32]
|
|
var prekeyPublicKeys: [Data]
|
|
/// IDs this device already sealed with (never reused).
|
|
var usedIDs: Set<UInt32>
|
|
/// messageID → prekey ID, so re-deposits of one message share one prekey.
|
|
var assignments: [String: UInt32]
|
|
var updatedAt: Date
|
|
}
|
|
|
|
enum Limits {
|
|
static let maxPeers = 200
|
|
/// Don't seal to bundles older than this: the owner may have rotated
|
|
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
|
|
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
|
|
}
|
|
|
|
static let shared = PrekeyBundleStore()
|
|
|
|
private var bundles: [Data: StoredBundle] = [:]
|
|
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
|
|
private let fileURL: URL?
|
|
private let maxPeers: Int
|
|
private let now: () -> Date
|
|
|
|
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
|
/// when `persistsToDisk` is false.
|
|
init(
|
|
persistsToDisk: Bool = true,
|
|
fileURL: URL? = nil,
|
|
maxPeers: Int = Limits.maxPeers,
|
|
now: @escaping () -> Date = Date.init
|
|
) {
|
|
self.now = now
|
|
self.maxPeers = maxPeers
|
|
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
|
loadFromDisk()
|
|
}
|
|
|
|
// MARK: - Ingest
|
|
|
|
/// Stores a bundle whose signature the caller has already verified
|
|
/// against the owner's announce-bound signing key. Returns false when an
|
|
/// equal-or-newer bundle is already cached (nothing changed).
|
|
@discardableResult
|
|
func ingest(_ bundle: PrekeyBundle) -> Bool {
|
|
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
|
|
!bundle.prekeys.isEmpty else { return false }
|
|
return queue.sync {
|
|
if let existing = bundles[bundle.noiseStaticPublicKey],
|
|
existing.generatedAt >= bundle.generatedAt {
|
|
return false
|
|
}
|
|
let previous = bundles[bundle.noiseStaticPublicKey]
|
|
let newIDs = Set(bundle.prekeys.map(\.id))
|
|
// Keep consumption state for IDs the fresh bundle still offers
|
|
// (a top-up keeps the owner's unconsumed keys); drop the rest.
|
|
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
|
|
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
|
|
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
|
|
noiseKey: bundle.noiseStaticPublicKey,
|
|
generatedAt: bundle.generatedAt,
|
|
prekeyIDs: bundle.prekeys.map(\.id),
|
|
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
|
|
usedIDs: carriedUsed,
|
|
assignments: carriedAssignments,
|
|
updatedAt: now()
|
|
)
|
|
enforceCapLocked()
|
|
persistLocked()
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Sealing support
|
|
|
|
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
|
|
func hasUsableBundle(for noiseKey: Data) -> Bool {
|
|
queue.sync {
|
|
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
|
|
return bundle.usedIDs.count < bundle.prekeyIDs.count
|
|
}
|
|
}
|
|
|
|
/// The prekey to seal a message with: the message's existing assignment if
|
|
/// any (re-deposits reuse it), else the lowest unused ID, which is then
|
|
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
|
|
/// spent — callers fall back to static sealing.
|
|
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
|
queue.sync {
|
|
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
|
|
|
|
if let assigned = bundle.assignments[messageID],
|
|
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
|
|
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
|
|
}
|
|
|
|
guard let index = bundle.prekeyIDs.indices
|
|
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
|
|
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
|
|
return nil
|
|
}
|
|
let id = bundle.prekeyIDs[index]
|
|
bundle.usedIDs.insert(id)
|
|
bundle.assignments[messageID] = id
|
|
bundle.updatedAt = now()
|
|
bundles[recipientNoiseKey] = bundle
|
|
persistLocked()
|
|
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
|
|
}
|
|
}
|
|
|
|
// MARK: - Maintenance
|
|
|
|
/// Panic wipe: drop all cached bundles from memory and disk.
|
|
func wipe() {
|
|
queue.sync {
|
|
bundles.removeAll()
|
|
if let fileURL {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Internals (call only on `queue`)
|
|
|
|
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
|
|
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
|
|
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
|
|
}
|
|
|
|
private func enforceCapLocked() {
|
|
while bundles.count > maxPeers {
|
|
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
|
|
bundles.removeValue(forKey: victim.key)
|
|
}
|
|
}
|
|
|
|
private func persistLocked() {
|
|
guard let fileURL else { return }
|
|
do {
|
|
if bundles.isEmpty {
|
|
try? FileManager.default.removeItem(at: fileURL)
|
|
return
|
|
}
|
|
try FileManager.default.createDirectory(
|
|
at: fileURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
let data = try JSONEncoder().encode(Array(bundles.values))
|
|
var options: Data.WritingOptions = [.atomic]
|
|
#if os(iOS)
|
|
options.insert(.completeFileProtection)
|
|
#endif
|
|
try data.write(to: fileURL, options: options)
|
|
} catch {
|
|
SecureLogger.error("Failed to persist prekey bundle store: \(error)", category: .security)
|
|
}
|
|
}
|
|
|
|
private func loadFromDisk() {
|
|
guard let fileURL else { return }
|
|
queue.sync {
|
|
guard let data = try? Data(contentsOf: fileURL),
|
|
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
|
|
return
|
|
}
|
|
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
|
|
bundles[bundle.noiseKey] = bundle
|
|
}
|
|
}
|
|
}
|
|
|
|
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("prekeys", isDirectory: true)
|
|
.appendingPathComponent("bundles.json")
|
|
}
|
|
}
|