Harden PTT audio and the 1.7.1 release (#1423)

Centralize PTT and voice audio-session ownership, harden courier/bridge/outbox delivery and recovery, correct location and delivery-state races, add privacy/release metadata, and ship reproducible universal Arti slices with Release CI coverage.

Validated by the full iOS suite, repeated audio/fragment/performance regressions, BitFoundation tests, strict lint and dead-code analysis, universal iOS Release builds, and iOS/macOS archives.
This commit is contained in:
jack
2026-07-10 14:04:09 +02:00
committed by GitHub
parent b205a55523
commit 820c933958
91 changed files with 8381 additions and 1083 deletions
@@ -18,6 +18,7 @@ enum BLEFanoutSelector {
preferredPeripheralPerPeer: [PeerID: String] = [:],
collapseDuplicatePeerLinks: Bool = true,
directedPeerHint: PeerID?,
requireDirectPeerLink: Bool = false,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
@@ -38,6 +39,9 @@ enum BLEFanoutSelector {
) {
return directedSelection
}
if directedPeerHint != nil, requireDirectPeerLink {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
@@ -7,6 +7,26 @@ struct BLEOutboundFragmentTransferRequest {
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
let requireDirectPeerLink: Bool
let requireNoiseAuthenticatedPeerLink: Bool
init(
packet: BitchatPacket,
pad: Bool,
maxChunk: Int?,
directedPeer: PeerID?,
transferId: String?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) {
self.packet = packet
self.pad = pad
self.maxChunk = maxChunk
self.directedPeer = directedPeer
self.transferId = transferId
self.requireDirectPeerLink = requireDirectPeerLink
self.requireNoiseAuthenticatedPeerLink = requireNoiseAuthenticatedPeerLink
}
var resolvedTransferId: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
@@ -22,6 +42,21 @@ struct BLEOutboundFragmentTransferRequest {
}
}
/// Transactional admission for strict fragment trains. Durable callers may
/// commit only when every fragment was accepted; the first rejection stops
/// the train and reports failure so the original remains retryable.
enum BLEStrictFragmentAdmission {
static func admitAll<Fragment>(
_ fragments: [Fragment],
accepting: (Fragment) -> Bool
) -> Bool {
for fragment in fragments where !accepting(fragment) {
return false
}
return true
}
}
struct BLEOutboundFragmentTransferScheduler {
enum QueuePosition {
case front
@@ -31,6 +66,11 @@ struct BLEOutboundFragmentTransferScheduler {
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
/// Strict direct-link requests are transactional: returning false to
/// their durable owner must mean no process-local copy remains that
/// can transmit later. They are therefore start-or-reject, never
/// admitted to `pendingTransfers`.
case rejectedStrict(request: BLEOutboundFragmentTransferRequest, transferId: String?)
/// The same file is already being (or waiting to be) fragmented out
/// to an audience covering this request; sending it again would just
/// double the airtime (field-verified: one 41KB voice file went out
@@ -113,11 +153,17 @@ struct BLEOutboundFragmentTransferScheduler {
}
guard activeTransfers.count < maxConcurrentTransfers else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
if request.requireDirectPeerLink {
return .rejectedStrict(request: request, transferId: transferId)
}
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
@@ -22,21 +22,9 @@ enum BLEOutboundLinkPlanner {
centralPeerBindings: [String: PeerID] = [:],
preferredPeripheralPerPeer: [PeerID: String] = [:],
directAnnounceTTL: UInt8 = TransportConfig.messageTTLDefault,
directedOnlyPeer: PeerID?
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
// Direct announces bypass the per-peer duplicate-link collapse so
// every live link gets bound (see BLEFanoutSelector.selectLinks).
@@ -51,10 +39,34 @@ enum BLEOutboundLinkPlanner {
preferredPeripheralPerPeer: preferredPeripheralPerPeer,
collapseDuplicatePeerLinks: !isDirectAnnounce,
directedPeerHint: directedPeerHint,
requireDirectPeerLink: requireDirectPeerLink,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
// Fragment only for links that this packet can actually use. Looking
// at every connected link before directed-peer selection lets an
// unrelated peer's MTU make an oversized directed send look routable,
// even though every resulting fragment will select zero target links.
let selectedPeripheralLimits = zip(peripheralIDs, peripheralWriteLimits).compactMap { id, limit in
selectedLinks.peripheralIDs.contains(id) ? limit : nil
}
let selectedCentralLimits = zip(centralIDs, centralNotifyLimits).compactMap { id, limit in
selectedLinks.centralIDs.contains(id) ? limit : nil
}
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: selectedPeripheralLimits,
centralNotifyLimits: selectedCentralLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: false
)
}
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
@@ -44,4 +44,16 @@ struct BLEOutboundNotificationBuffer<Target> {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
/// Removes a disconnected target from target-specific retries. Broadcast
/// entries (`targets == nil`) remain valid for the surviving subscriber
/// set; an entry with no targets left is discarded entirely.
mutating func removeTarget(where matches: (Target) -> Bool) {
notifications = notifications.compactMap { notification in
guard let targets = notification.targets else { return notification }
let remaining = targets.filter { !matches($0) }
guard !remaining.isEmpty else { return nil }
return BLEPendingNotification(data: notification.data, targets: remaining)
}
}
}
@@ -46,8 +46,25 @@ struct BLEOutboundWriteBuffer {
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
enqueueReportingAcceptance(
data: data,
for: peripheralID,
priority: priority,
capBytes: capBytes
).result
}
/// Enqueues while also reporting whether the newly offered write survived
/// priority trimming. `EnqueueResult.enqueued` alone cannot express that:
/// a full queue may immediately trim the new lowest-priority item.
mutating func enqueueReportingAcceptance(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> (result: EnqueueResult, accepted: Bool) {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
return (.oversized(bytes: data.count), false)
}
var queue = writesByPeripheralID[peripheralID] ?? []
@@ -65,7 +82,8 @@ struct BLEOutboundWriteBuffer {
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
let accepted = insertIndex < queue.count
return (.enqueued(trimmedBytes: trimmedBytes, remainingBytes: total), accepted)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
@@ -74,6 +92,13 @@ struct BLEOutboundWriteBuffer {
return items
}
/// Drops link-specific ciphertext when that physical link is gone. Keeping
/// the dictionary entry would let rotating peripheral UUIDs accumulate a
/// fresh per-link byte cap indefinitely.
mutating func discardAll(for peripheralID: String) {
writesByPeripheralID[peripheralID] = nil
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
+454 -54
View File
@@ -37,6 +37,12 @@ final class BLEService: NSObject {
// 1. Consolidated BLE link tracking for both central and peripheral roles.
private var linkStateStore = BLELinkStateStore()
// A peer ID can retain an established Noise session after its physical
// link disappears. Courier handover therefore needs the stronger fact
// that the session was established *on this current ingress link*, not
// merely that some session exists for the claimed ID. bleQueue-owned.
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
// store): entries older than the cooldown are pruned on insert.
private var lastLinkRebindAt: [String: Date] = [:]
@@ -680,6 +686,7 @@ final class BLEService: NSObject {
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
bleQueue.sync {
linkStateStore.clearAll()
noiseAuthenticatedLinkOwners.removeAll()
connectionScheduler.reset()
subscriptionAnnounceLimiter.removeAll()
}
@@ -1188,9 +1195,91 @@ final class BLEService: NSObject {
}
}
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: PeerID?) {
/// Synchronously admits a notification to the link-specific retry queue.
/// Destructive courier handoff uses this result as its commit point, so a
/// full process-local queue must be reported as rejection, not success.
private func enqueuePendingNotificationIfAccepted(
data: Data,
centrals: [CBCentral],
context: String
) -> Bool {
let result = collectionsQueue.sync(flags: .barrier) {
pendingNotifications.enqueue(
data: data,
targets: centrals,
capCount: TransportConfig.blePendingNotificationsCapCount
)
}
switch result {
case let .enqueued(count):
SecureLogger.debug("📋 Queued \(context) packet for retry (pending=\(count))", category: .session)
return true
case let .full(count):
SecureLogger.warning("⚠️ Rejecting \(context) packet: notification queue full (pending=\(count))", category: .session)
return false
}
}
/// Serializes the final authenticated-link check with CoreBluetooth's
/// notification admission on `bleQueue`, closing the rebind/disconnect
/// race between fanout planning and the actual handoff.
private func notifyOrEnqueueIfAccepted(
data: Data,
centrals: [CBCentral],
characteristic: CBMutableCharacteristic,
context: String,
requiredAuthenticatedPeer: PeerID?
) -> Bool {
let accept = { [self] in
let eligible: [CBCentral]
if let peerID = requiredAuthenticatedPeer {
eligible = centrals.filter { central in
let link = BLEIngressLinkID.central(central.identifier.uuidString)
return noiseAuthenticatedLinkOwners[link] == peerID
&& linkStateStore.peerID(forCentralUUID: central.identifier.uuidString) == peerID
}
} else {
eligible = centrals
}
guard !eligible.isEmpty else { return false }
if peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: eligible) == true {
return true
}
return enqueuePendingNotificationIfAccepted(
data: data,
centrals: eligible,
context: context
)
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return accept()
}
return bleQueue.sync(execute: accept)
}
/// Returns true only when the packet was accepted by at least one current
/// physical link (including its link-specific backpressure queue). A
/// process-local directed spool is deliberately not success: callers
/// that own a durable upstream copy must keep it retryable.
@discardableResult
private func sendOnAllLinks(
packet: BitchatPacket,
data: Data,
pad: Bool,
directedOnlyPeer: PeerID?,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
let excludedPeerLinks = links(to: ingressRecord?.peerID)
var excludedPeerLinks = links(to: ingressRecord?.peerID)
if requireNoiseAuthenticatedPeerLink {
guard let directedOnlyPeer else { return false }
let boundLinks = links(to: directedOnlyPeer)
let authenticatedLinks = currentNoiseAuthenticatedLinks(to: directedOnlyPeer)
guard !authenticatedLinks.isEmpty else { return false }
excludedPeerLinks.formUnion(boundLinks.subtracting(authenticatedLinks))
}
let outboundPriority = BLEOutboundPacketPolicy.priority(for: packet, data: data)
let states = snapshotPeripheralStates()
@@ -1222,12 +1311,22 @@ final class BLEService: NSObject {
// as a combined snapshot.
preferredPeripheralPerPeer: readLinkState { $0.preferredPeripheralBindings },
directAnnounceTTL: messageTTL,
directedOnlyPeer: directedOnlyPeer
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink
)
if let chunk = plan.fragmentChunkSize {
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
return
guard !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty else {
return false
}
return sendFragmentedPacket(
packet,
pad: pad,
maxChunk: chunk,
directedOnlyPeer: directedOnlyPeer,
requireDirectPeerLink: requireDirectPeerLink || requireNoiseAuthenticatedPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
}
// If directed and we currently have no links to forward on, spool for a short window
@@ -1236,36 +1335,73 @@ final class BLEService: NSObject {
spoolDirectedPacket(packet, recipientPeerID: only)
}
var acceptedByPhysicalLink = false
// Writes to selected connected peripherals
for s in connectedStates {
let pid = s.peripheral.identifier.uuidString
guard plan.selectedLinks.peripheralIDs.contains(pid) else { continue }
if let ch = s.characteristic {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
acceptedByPhysicalLink = writeOrEnqueueIfAccepted(
data,
to: s.peripheral,
characteristic: ch,
priority: outboundPriority,
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
) || acceptedByPhysicalLink
} else {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch, priority: outboundPriority)
}
}
}
// Notify selected subscribed centrals
if let ch = characteristic {
let targets = subscribedCentrals.filter { plan.selectedLinks.centralIDs.contains($0.identifier.uuidString) }
if !targets.isEmpty {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
if !success {
// Notification queue full - queue for retry to prevent silent packet loss
// This is critical for fragment delivery reliability
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
enqueuePendingNotification(data: data, centrals: targets, context: context)
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink {
acceptedByPhysicalLink = notifyOrEnqueueIfAccepted(
data: data,
centrals: targets,
characteristic: ch,
context: "directed",
requiredAuthenticatedPeer: requireNoiseAuthenticatedPeerLink ? directedOnlyPeer : nil
) || acceptedByPhysicalLink
} else {
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
if !success {
// Notification queue full - queue for retry to prevent silent packet loss
// This is critical for fragment delivery reliability
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
enqueuePendingNotification(data: data, centrals: targets, context: context)
}
}
}
}
if requireDirectPeerLink || requireNoiseAuthenticatedPeerLink { return acceptedByPhysicalLink }
return !plan.selectedLinks.peripheralIDs.isEmpty || !plan.selectedLinks.centralIDs.isEmpty
}
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
@discardableResult
private func sendPacketDirected(
_ packet: BitchatPacket,
to peerID: PeerID,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
guard let data = packet.toBinaryData(padding: false) else { return false }
return sendOnAllLinks(
packet: packet,
data: data,
pad: false,
directedOnlyPeer: peerID,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
}
// MARK: - Directed store-and-forward
@@ -1938,6 +2074,10 @@ extension BLEService: CBCentralManagerDelegate {
#endif
// Clean up references and peer mappings
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
// A duplicate link can drop while the peer stays live on another
// (the dual-role central link, or a second bound link after a
@@ -1988,6 +2128,10 @@ extension BLEService: CBCentralManagerDelegate {
let peripheralID = peripheral.identifier.uuidString
// Clean up the references
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: peripheralID)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = linkStateStore.removePeripheral(peripheralID)
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
@@ -2088,6 +2232,10 @@ extension BLEService {
SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
self.tryConnectFromQueue()
@@ -2134,6 +2282,23 @@ extension BLEService {
handleReceivedPacket(packet, from: fromPeerID)
}
/// Waits until fragment ingress already submitted by a test has finished
/// reassembly/reinjection and any resulting transport event has crossed
/// the MainActor delivery hop. This is a deterministic pipeline fence,
/// avoiding wall-clock sleeps that become flaky under a parallel suite.
func _test_drainFragmentPipeline() async {
await withCheckedContinuation { continuation in
messageQueue.async(flags: .barrier) {
// Reassembled packets are reinjected synchronously on
// `messageQueue`; their UI delivery task is therefore already
// enqueued before this later MainActor marker.
Task { @MainActor in
continuation.resume()
}
}
}
}
func _test_hasGossipPrekeyBundle(for peerID: PeerID) -> Bool {
gossipSyncManager?._hasPrekeyBundle(for: peerID) ?? false
}
@@ -2164,6 +2329,13 @@ extension BLEService {
bleQueue.sync { linkStateStore.peerID(forCentralUUID: centralUUID) }
}
func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
bleQueue.sync {
guard linkStateStore.peerID(forCentralUUID: centralUUID) == peerID else { return }
noiseAuthenticatedLinkOwners[.central(centralUUID)] = peerID
}
}
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
collectionsQueue.sync(flags: .barrier) {
peerRegistry.upsert(BLEPeerInfo(
@@ -2578,7 +2750,12 @@ extension BLEService: CBPeripheralManagerDelegate {
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
SecureLogger.debug("📤 Central unsubscribed: \(central.identifier.uuidString.prefix(8))", category: .session)
let centralID = central.identifier.uuidString
SecureLogger.debug("📤 Central unsubscribed: \(centralID.prefix(8))", category: .session)
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
// Ensure we're still advertising for other devices to find us
@@ -3118,6 +3295,45 @@ extension BLEService {
private func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
readLinkState { $0.links(to: peerID) }
}
private func boundPeerID(for link: BLEIngressLinkID, in store: BLELinkStateStore) -> PeerID? {
switch link {
case .peripheral(let peripheralUUID):
store.peerID(forPeripheralID: peripheralUUID)
case .central(let centralUUID):
store.peerID(forCentralUUID: centralUUID)
}
}
/// Marks the exact physical ingress link that completed a fresh Noise
/// handshake. An old session keyed only by peer ID is insufficient: a
/// replayed announce can rebind an attacker's link to that ID.
private func markNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return }
readLinkState { store in
guard boundPeerID(for: link, in: store) == peerID else { return }
noiseAuthenticatedLinkOwners[link] = peerID
}
}
private func isNoiseAuthenticatedIngressLink(for packet: BitchatPacket, peerID: PeerID) -> Bool {
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else { return false }
return readLinkState { store in
noiseAuthenticatedLinkOwners[link] == peerID && boundPeerID(for: link, in: store) == peerID
}
}
private func hasCurrentNoiseAuthenticatedLink(to peerID: PeerID) -> Bool {
!currentNoiseAuthenticatedLinks(to: peerID).isEmpty
}
private func currentNoiseAuthenticatedLinks(to peerID: PeerID) -> Set<BLEIngressLinkID> {
readLinkState { store in
Set(noiseAuthenticatedLinkOwners.compactMap { link, owner in
owner == peerID && boundPeerID(for: link, in: store) == peerID ? link : nil
})
}
}
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
service.onPeerAuthenticated = { [weak self] peerID, fingerprint in
@@ -3299,22 +3515,32 @@ extension BLEService {
guard CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) else {
return false
}
openCourierEnvelope(envelope)
return true
return openCourierEnvelope(envelope)
}
/// Hands a bridge-fetched envelope directly to the matching local peer
/// as a directed courier packet. Delivery-only by design: the recipient's
/// tag matched, so this never lands in a stranger's carry quota.
/// Returns true only if a current Noise-authenticated physical link
/// accepted the packet; a stale peer-level session, reachability record,
/// replay-rebound link, or process-local spool is not delivery.
@discardableResult
func deliverBridgedEnvelope(_ envelope: CourierEnvelope, to peerID: PeerID) -> Bool {
guard isPeerConnected(peerID) || isPeerReachable(peerID) else { return false }
guard hasCurrentNoiseAuthenticatedLink(to: peerID) else { return false }
guard let payload = envelope.encode() else { return false }
let packet = makeCourierPacket(payload, to: peerID)
messageQueue.async { [weak self] in
self?.sendPacketDirected(packet, to: peerID)
let send = { [weak self] in
self?.sendPacketDirected(
packet,
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) ?? false
}
return true
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
return send()
}
return messageQueue.sync(execute: send)
}
/// Our own Noise static public key (for computing our courier tags).
@@ -3385,7 +3611,8 @@ extension BLEService {
}
}
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
@discardableResult
private func openCourierEnvelope(_ envelope: CourierEnvelope) -> Bool {
do {
let typedPayload: Data
let senderStaticKey: Data
@@ -3410,12 +3637,12 @@ extension BLEService {
let payloadType = NoisePayloadType(rawValue: typeRaw),
payloadType == .privateMessage else {
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
return
return true // decrypted but deterministically unsupported
}
let payload = Data(typedPayload.dropFirst())
guard let innerMessageID = PrivateMessagePacket.decode(from: payload)?.messageID else {
SecureLogger.warning("⚠️ Courier envelope carried undecodable private message", category: .session)
return
return true // decrypted but deterministically malformed
}
// Redundant copies of one message arrive as distinct envelopes
// (fresh seal each: mesh couriers, bridge drops across relays),
@@ -3427,14 +3654,14 @@ extension BLEService {
}
guard firstOpen else {
SecureLogger.debug("📦 Dropping duplicate courier envelope for message \(innerMessageID.prefix(8))", category: .session)
return
return true
}
// Couriered mail arrives while the sender is absent, so the UI's
// block check can't resolve their fingerprint from a live session.
// Gate here, where the full static key is in hand.
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
return
return true
}
// A present sender resolves to their live mesh thread via the
// derived short ID. An absent sender the usual courier case
@@ -3454,9 +3681,11 @@ extension BLEService {
timestamp: Date()
))
}
return true
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
return false
}
}
@@ -3498,13 +3727,23 @@ extension BLEService {
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
sfMetrics?.record(.courierHandedOver)
let metrics = sfMetrics
let accepted = courierStore.handoverEnvelopes(for: noiseKey) { [weak self] envelope in
guard let self,
let payload = envelope.encode(),
self.sendPacketDirected(
self.makeCourierPacket(payload, to: peerID),
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) else {
return false
}
metrics?.record(.courierHandedOver)
return true
}
if accepted > 0 {
SecureLogger.debug("📦 Handed over \(accepted) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
}
}
@@ -3534,13 +3773,23 @@ extension BLEService {
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)
let sendSpray: () -> Void = { [weak self] in
guard let self else { return }
let accepted = store.transferSprayCopies(to: noiseKey) { envelope in
guard let payload = envelope.encode(),
self.sendPacketDirected(
self.makeCourierPacket(payload, to: peerID),
to: peerID,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: true
) else {
return false
}
metrics?.record(.courierSprayed)
return true
}
if accepted > 0 {
SecureLogger.debug("📦 Sprayed \(accepted) envelope copy(ies) to courier \(peerID.id.prefix(8))", category: .session)
}
}
let policy = courierDepositPolicy
@@ -3548,7 +3797,7 @@ extension BLEService {
// 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))
sendSpray()
}
}
@@ -3829,6 +4078,62 @@ extension BLEService {
}
}
/// Writes immediately or synchronously admits the packet to this
/// peripheral's bounded retry queue. Unlike `writeOrEnqueue`, the return
/// value distinguishes a retained queue item from one rejected or trimmed
/// immediately, which lets durable courier state commit truthfully.
private func writeOrEnqueueIfAccepted(
_ data: Data,
to peripheral: CBPeripheral,
characteristic: CBCharacteristic,
priority: BLEOutboundWritePriority,
requiredAuthenticatedPeer: PeerID?
) -> Bool {
let accept = { [self] in
let uuid = peripheral.identifier.uuidString
guard let state = linkStateStore.state(forPeripheralID: uuid),
state.isConnected,
state.characteristic?.uuid == characteristic.uuid else {
return false
}
if let peerID = requiredAuthenticatedPeer {
let link = BLEIngressLinkID.peripheral(uuid)
guard state.peerID == peerID,
noiseAuthenticatedLinkOwners[link] == peerID else {
return false
}
}
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
return true
}
let attempt = collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.enqueueReportingAcceptance(
data: data,
for: uuid,
priority: priority,
capBytes: TransportConfig.blePendingWriteBufferCapBytes
)
}
switch attempt.result {
case .oversized(let bytes):
SecureLogger.warning("⚠️ Rejecting oversized write chunk (\(bytes)B) for peripheral \(uuid)", category: .session)
case let .enqueued(trimmedBytes, remainingBytes) where trimmedBytes > 0:
SecureLogger.warning("📉 Trimmed pending write buffer for \(uuid) by \(trimmedBytes)B to \(remainingBytes)B", category: .session)
case .enqueued:
break
}
return attempt.accepted
}
if DispatchQueue.getSpecific(key: bleQueueKey) != nil {
return accept()
}
return bleQueue.sync(execute: accept)
}
private func drainPendingWrites(for peripheral: CBPeripheral) {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
@@ -3976,6 +4281,10 @@ extension BLEService {
guard age > TransportConfig.bleConnectTimeoutSeconds else { continue }
let peripheralID = state.peripheral.identifier.uuidString
central.cancelPeripheralConnection(state.peripheral)
self.collectionsQueue.sync(flags: .barrier) {
self.pendingPeripheralWrites.discardAll(for: peripheralID)
}
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
_ = self.linkStateStore.removePeripheral(peripheralID)
cancelled += 1
}
@@ -4119,25 +4428,37 @@ extension BLEService {
// MARK: Fragmentation (Required for messages > BLE MTU)
private func sendFragmentedPacket(_ packet: BitchatPacket, pad: Bool, maxChunk: Int? = nil, directedOnlyPeer: PeerID? = nil, transferId: String? = nil) {
@discardableResult
private func sendFragmentedPacket(
_ packet: BitchatPacket,
pad: Bool,
maxChunk: Int? = nil,
directedOnlyPeer: PeerID? = nil,
transferId: String? = nil,
requireDirectPeerLink: Bool = false,
requireNoiseAuthenticatedPeerLink: Bool = false
) -> Bool {
let request = BLEOutboundFragmentTransferRequest(
packet: packet,
pad: pad,
maxChunk: maxChunk,
directedPeer: directedOnlyPeer,
transferId: transferId
transferId: transferId,
requireDirectPeerLink: requireDirectPeerLink,
requireNoiseAuthenticatedPeerLink: requireNoiseAuthenticatedPeerLink
)
let result = collectionsQueue.sync(flags: .barrier) {
outboundFragmentTransfers.submit(request, maxConcurrentTransfers: TransportConfig.bleMaxConcurrentTransfers)
}
handleFragmentTransferSubmitResult(result)
return handleFragmentTransferSubmitResult(result)
}
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) {
@discardableResult
private func handleFragmentTransferSubmitResult(_ result: BLEOutboundFragmentTransferScheduler.SubmitResult) -> Bool {
switch result {
case let .start(request, reservedTransferId):
startFragmentedPacket(request, reservedTransferId: reservedTransferId)
return startFragmentedPacket(request, reservedTransferId: reservedTransferId)
case let .queued(_, transferId, _):
if let transferId {
@@ -4145,16 +4466,29 @@ extension BLEService {
} else {
SecureLogger.debug("🚦 Queued fragment transfer waiting for slot", category: .session)
}
return false
case let .rejectedStrict(_, transferId):
SecureLogger.debug(
"🚫 Strict directed fragment transfer \(transferId?.prefix(8) ?? "?")… rejected while scheduler busy",
category: .session
)
return false
case let .droppedDuplicate(_, activeTransferId):
SecureLogger.debug(
"🔁 Skipping duplicate outbound transfer — same content already in flight as \(activeTransferId?.prefix(8) ?? "?")",
category: .session
)
return false
}
}
private func startFragmentedPacket(_ request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?) {
@discardableResult
private func startFragmentedPacket(
_ request: BLEOutboundFragmentTransferRequest,
reservedTransferId: String?
) -> Bool {
let releaseReservedSlot: (String) -> Void = { [weak self] id in
guard let self = self else { return }
TransferProgressManager.shared.cancel(id: id)
@@ -4174,7 +4508,7 @@ extension BLEService {
if let id = reservedTransferId {
releaseReservedSlot(id)
}
return
return false
}
// Lightweight pacing to reduce floods and allow BLE buffers to drain
@@ -4200,6 +4534,42 @@ extension BLEService {
return id
}()
let sendFragment: (BitchatPacket) -> Bool = { [weak self] fragmentPacket in
guard let self else { return false }
if request.requireDirectPeerLink, let directedPeer = request.directedPeer {
return self.sendPacketDirected(
fragmentPacket,
to: directedPeer,
requireDirectPeerLink: true,
requireNoiseAuthenticatedPeerLink: request.requireNoiseAuthenticatedPeerLink
)
}
self.broadcastPacket(fragmentPacket)
return true
}
// Strict courier handoff is transactional at the fragment-admission
// boundary: every fragment must enter the intended authenticated
// link or its bounded retry queue before the durable owner may commit.
// A partial train is harmlessly abandoned and the envelope stays
// retryable with a fresh fragment ID on the next encounter.
if request.requireDirectPeerLink {
let admitted = BLEStrictFragmentAdmission.admitAll(plan.fragmentPackets) { fragmentPacket in
guard sendFragment(fragmentPacket) else { return false }
if let transferId = transferIdentifier {
markFragmentSent(transferId: transferId)
}
return true
}
guard admitted else {
if let id = reservedTransferId {
releaseReservedSlot(id)
}
return false
}
return true
}
var scheduledItems: [(item: DispatchWorkItem, index: Int)] = []
for (index, fragmentPacket) in plan.fragmentPackets.enumerated() {
@@ -4212,7 +4582,7 @@ extension BLEService {
if fragmentPacket.recipientID == nil || fragmentPacket.recipientID?.allSatisfy({ $0 == 0xFF }) == true {
self.gossipSyncManager?.onPublicPacketSeen(fragmentPacket)
}
self.broadcastPacket(fragmentPacket)
_ = sendFragment(fragmentPacket)
if let transferId = transferIdentifier {
self.markFragmentSent(transferId: transferId)
}
@@ -4232,6 +4602,7 @@ extension BLEService {
let delayMs = index * plan.spacingMs
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs), execute: workItem)
}
return true
}
// MARK: - Fragmentation (Required for messages > BLE MTU)
@@ -4503,15 +4874,32 @@ extension BLEService {
let result,
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.
let authenticatedIngress = result.isDirectAnnounce
&& canDeliverSecurely(to: result.peerID)
&& isNoiseAuthenticatedIngressLink(for: packet, peerID: result.peerID)
if authenticatedIngress {
// The session was established on this still-bound ingress link.
// A peer-level Noise session alone is not enough: it can outlive
// its physical link while a replay rebinds an attacker's link to
// the victim's ID.
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.
// Relayed announce, or a direct-looking announce that has not yet
// proved link ownership with Noise: push a speculative copy while
// retaining the durable carried original.
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
if result.isDirectAnnounce,
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
if noiseService.hasEstablishedSession(with: result.peerID) {
// A session with no surviving authenticated link is stale;
// force the current link to prove possession again.
noiseService.clearSession(for: result.peerID)
}
if !noiseService.hasSession(with: result.peerID) {
initiateNoiseHandshake(with: result.peerID)
}
}
}
}
@@ -4559,6 +4947,10 @@ extension BLEService {
}
self.lastLinkRebindAt[linkUUID] = now
// A Noise proof belongs to the old physical binding. Never carry
// it across an announce-driven rebind, whose direct TTL is
// replayable; the new owner must complete a fresh handshake.
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
switch link {
case .peripheral(let peripheralUUID):
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
@@ -4652,6 +5044,10 @@ extension BLEService {
)
for uuid in retiring {
guard let state = linkStateStore.state(forPeripheralID: uuid) else { continue }
collectionsQueue.sync(flags: .barrier) {
pendingPeripheralWrites.discardAll(for: uuid)
}
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
_ = linkStateStore.removePeripheral(uuid)
SecureLogger.info(
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
@@ -5029,7 +5425,11 @@ extension BLEService {
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
noisePacketHandler.handleHandshake(packet, from: peerID)
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
}
}
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
+291 -41
View File
@@ -10,6 +10,9 @@ import BitFoundation
import BitLogger
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
/// 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
@@ -120,13 +123,45 @@ final class CourierStore {
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
private let readData: (URL) throws -> Data
/// A protected file can be present but unreadable during an iOS
/// background restoration before first unlock. Keep that distinct from
/// an absent file: mutations may proceed in memory, but must not replace
/// the unreadable durable snapshot until it can be merged.
private var diskLoadDeferred = false
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
init(
persistsToDisk: Bool = true,
fileURL: URL? = nil,
now: @escaping () -> Date = Date.init,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) }
) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
self.readData = readData
loadFromDisk()
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredPersistence()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// MARK: - Depositing (courier side)
@@ -154,10 +189,16 @@ final class CourierStore {
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
// Identical ciphertext is the same envelope. Before any spray,
// a carry-only copy may legitimately arrive ahead of the original
// higher-budget copy, so keep the larger initial budget. Once a
// branch has sprayed, however, replaying the depositor's original
// packet must never replenish spent copies: that would defeat
// spray-and-wait and let `sprayedTo` grow without bound.
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
if envelopes[existing].sprayedTo.isEmpty {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
}
persistLocked()
return true
}
@@ -207,20 +248,52 @@ final class CourierStore {
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
/// the rotating recipient tag across adjacent days. This compatibility
/// helper accepts every offer; transport callers should use
/// `handoverEnvelopes(for:accepting:)` so failed sends remain durable.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
var handedOver: [CourierEnvelope] = []
handoverEnvelopes(for: noiseStaticKey) { envelope in
handedOver.append(envelope)
return true
}
return handedOver
}
/// Attempts direct handover without retiring the durable carried copy
/// until the transport accepts it onto the intended peer's physical link.
/// A failed encode, stale binding, or backpressure rejection leaves the
/// envelope unchanged for the next authenticated encounter.
@discardableResult
func handoverEnvelopes(
for noiseStaticKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
return envelopes
.filter { candidates.contains($0.recipientTag) }
.map(\.envelope)
}
var acceptedCount = 0
for envelope in offered where accepting(envelope) {
// Do not hold the store queue while the acceptance closure enters
// BLE/collections queues. Commit in a second short critical
// section, rechecking that another handover did not win first.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return false
}
envelopes.remove(at: index)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
@@ -247,27 +320,33 @@ final class CourierStore {
}
}
/// Envelopes to park on relays as bridge courier drops. Non-destructive
/// like remote handover the relay copy is speculative, so the carried
/// copy stays until direct handover or expiry. The per-envelope cooldown
/// keeps relay churn from republishing the same mail; publishing relays
/// dedup identical events by ID anyway.
/// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only
/// after a relay explicitly accepts the event via NIP-20 OK.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices {
if let last = envelopes[index].lastBridgePublishAt,
return envelopes.compactMap { stored in
if let last = stored.lastBridgePublishAt,
date.timeIntervalSince(last) < cooldown {
continue
return nil
}
envelopes[index].lastBridgePublishAt = date
// The relay copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
return stored.envelope.withCopies(1)
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
/// Starts the bridge-publish cooldown only for a relay-confirmed copy.
func markBridgePublished(_ envelope: CourierEnvelope) {
let date = now()
queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) else {
return
}
envelopes[index].lastBridgePublishAt = date
persistLocked()
}
}
@@ -278,25 +357,60 @@ final class CourierStore {
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
var sprayed: [CourierEnvelope] = []
transferSprayCopies(to: courierNoiseKey) { envelope in
sprayed.append(envelope)
return true
}
return sprayed
}
/// Offers binary-spray copies one at a time and commits the reduced local
/// budget plus `sprayedTo` marker only after the directed transport accepts
/// that copy. A false result is a rollback: retrying the same courier sees
/// the original budget and eligibility.
@discardableResult
func transferSprayCopies(
to courierNoiseKey: Data,
accepting: (CourierEnvelope) -> Bool
) -> Int {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
let offered = queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
return envelopes.compactMap { stored -> CourierEnvelope? in
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))
!courierTags.contains(stored.recipientTag) else { return nil }
return stored.envelope.withCopies(stored.copies / 2)
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
var acceptedCount = 0
for copy in offered where accepting(copy) {
// As with direct handover, BLE acceptance runs outside the store
// queue. Revalidate and commit the exact budget that left this
// device; a competing successful transfer makes this a no-op.
let committed = queue.sync {
guard let index = envelopes.firstIndex(where: { $0.ciphertext == copy.ciphertext }) else {
return false
}
let stored = envelopes[index]
guard stored.copies > copy.copies,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else {
return false
}
envelopes[index].copies = stored.copies - copy.copies
envelopes[index].sprayedTo.insert(courierNoiseKey)
persistLocked()
return true
}
if committed { acceptedCount += 1 }
}
return acceptedCount
}
// MARK: - Maintenance
@@ -305,6 +419,7 @@ final class CourierStore {
func wipe() {
queue.sync {
envelopes.removeAll()
diskLoadDeferred = false
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
@@ -312,6 +427,16 @@ final class CourierStore {
}
}
/// Retries a protected-data read and merges any envelopes accepted while
/// the file was unavailable. Internal so persistence tests can drive the
/// same transition as iOS's protected-data notification.
func retryDeferredPersistence() {
queue.sync {
guard diskLoadDeferred, resolveDeferredLoadLocked() else { return }
persistLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
@@ -332,6 +457,12 @@ final class CourierStore {
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
// Never turn a transient protected-data read failure into an
// authoritative empty/new file. Once readable, resolve first by
// merging the durable and in-memory snapshots.
if diskLoadDeferred, !resolveDeferredLoadLocked() {
return
}
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
@@ -344,7 +475,7 @@ final class CourierStore {
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
@@ -355,16 +486,135 @@ final class CourierStore {
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return
}
envelopes = stored
do {
let data = try readData(fileURL)
do {
envelopes = try JSONDecoder().decode([StoredEnvelope].self, from: data)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
} catch {
// The bytes were readable, so this is corruption/schema
// failure rather than protected-data unavailability.
diskLoadDeferred = false
SecureLogger.error("Failed to decode courier store: \(error)", category: .session)
}
} catch {
diskLoadDeferred = true
SecureLogger.warning("Courier store unavailable; deferring load until protected data is available: \(error)", category: .session)
}
}
}
/// Must be called on `queue`.
private func resolveDeferredLoadLocked() -> Bool {
guard diskLoadDeferred, let fileURL else { return true }
guard FileManager.default.fileExists(atPath: fileURL.path) else {
diskLoadDeferred = false
return true
}
do {
let data = try readData(fileURL)
let durable = try JSONDecoder().decode([StoredEnvelope].self, from: data)
envelopes = Self.merge(durable: durable, inMemory: envelopes)
diskLoadDeferred = false
pruneExpiredLocked(at: now())
publishCountLocked()
Self.migrateFileProtectionIfNeeded(at: fileURL)
return true
} catch let error as DecodingError {
// Readability returned but the snapshot is corrupt. Do not pin
// persistence forever; retain the valid in-memory snapshot.
diskLoadDeferred = false
SecureLogger.error("Failed to decode deferred courier store: \(error)", category: .session)
return true
} catch {
SecureLogger.warning("Courier store still unavailable: \(error)", category: .session)
return false
}
}
private static func merge(durable: [StoredEnvelope], inMemory: [StoredEnvelope]) -> [StoredEnvelope] {
var merged = durable
for candidate in inMemory {
if let index = merged.firstIndex(where: { $0.ciphertext == candidate.ciphertext }) {
// Union progress before deciding the budget. Once either copy
// has sprayed, the lower remaining budget is authoritative;
// an older durable/original snapshot must not replenish it.
let durableHasProgress = !merged[index].sprayedTo.isEmpty
let memoryHasProgress = !candidate.sprayedTo.isEmpty
let combinedSprayedTo = merged[index].sprayedTo.union(candidate.sprayedTo)
switch (durableHasProgress, memoryHasProgress) {
case (false, false):
merged[index].copies = max(merged[index].copies, candidate.copies)
case (true, false):
break // durable progress owns its remaining budget
case (false, true):
merged[index].copies = candidate.copies
case (true, true):
// Concurrent progress can only spend budget; choosing the
// lower branch prevents a merge from minting copies.
merged[index].copies = min(merged[index].copies, candidate.copies)
}
merged[index].sprayedTo = combinedSprayedTo
if candidate.tier == .favorite { merged[index].tier = .favorite }
merged[index].lastRemoteHandoverAt = [merged[index].lastRemoteHandoverAt, candidate.lastRemoteHandoverAt]
.compactMap { $0 }
.max()
merged[index].lastBridgePublishAt = [merged[index].lastBridgePublishAt, candidate.lastBridgePublishAt]
.compactMap { $0 }
.max()
} else {
merged.append(candidate)
}
}
// A long locked wake can accept new mail alongside a full durable
// store. Re-apply deposit quotas: existing per-depositor mail keeps
// its slot, while total-cap eviction sheds oldest verified mail first.
merged.sort { $0.storedAt < $1.storedAt }
var perDepositorCounts: [Data: Int] = [:]
merged = merged.filter { envelope in
let limit = envelope.tier == .favorite
? Limits.maxPerFavoriteDepositor
: Limits.maxPerVerifiedDepositor
let count = perDepositorCounts[envelope.depositorNoiseKey, default: 0]
guard count < limit else { return false }
perDepositorCounts[envelope.depositorNoiseKey] = count + 1
return true
}
while merged.filter({ $0.tier == .verified }).count > Limits.maxVerifiedEnvelopes {
guard let victim = merged.firstIndex(where: { $0.tier == .verified }) else { break }
merged.remove(at: victim)
}
while merged.count > Limits.maxEnvelopes {
if let victim = merged.firstIndex(where: { $0.tier == .verified }) {
merged.remove(at: victim)
} else {
merged.removeFirst()
}
}
return merged
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate courier store file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
+729 -39
View File
@@ -11,6 +11,9 @@ import BitLogger
import CryptoKit
import Foundation
import Security
#if os(iOS)
import UIKit
#endif
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
@@ -60,76 +63,550 @@ final class MessageOutboxStore {
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
typealias Snapshot = [PeerID: [QueuedMessage]]
private enum DiskState {
case unknown
case loaded
case deferred
}
private enum DiskReadResult {
case missing
case loaded(Snapshot)
case deferred(Error?)
case corrupt(Error)
}
private enum EncryptionKeyReadResult {
case available(SymmetricKey)
case missing
case invalid
case unavailable(Error?)
}
private struct RecoveredSnapshot {
let snapshot: Snapshot
let generation: UInt64
let unseenDurable: Snapshot
}
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
private let readData: (URL) throws -> Data
private let writeData: (Data, URL, Data.WritingOptions) throws -> Void
private let beforeRecoveryNotification: () -> Void
private let lock = NSLock()
private var diskState: DiskState = .unknown
private var cachedSnapshot: Snapshot = [:]
/// Mutations made after a protected-data load failed. They are merged
/// with the durable snapshot once it becomes readable, never written on
/// top of an unreadable file.
private var pendingSnapshot: Snapshot?
/// True after a write failed after the durable baseline was already
/// loaded. That full-router snapshot includes removals and must replace,
/// rather than union with, the older disk contents on retry.
private var pendingSnapshotIsAuthoritative = false
/// Delivery/read acknowledgments received before a deferred cold-load
/// reveals the durable queue. Applied to every merge before persistence.
private var pendingRemovalMessageIDs = Set<String>()
private var recoveryHandler: (@MainActor (Snapshot) -> Void)?
/// Recovery loaded durable state that MessageRouter has not merged yet.
/// While true, router saves must union with `cachedSnapshot` instead of
/// replacing unseen durable messages.
private var recoveryDeliveryPending = false
/// Recovery read durable state and classified the unseen subset, but the
/// merged snapshot could not yet be persisted. Preserve that classification
/// across retries instead of treating the cached union as router-known.
private var unseenRecoveryPendingPersistence = false
/// MessageRouter's latest authoritative in-memory snapshot while a
/// recovery classification is awaiting persistence or delivery. This is
/// deliberately separate from `pendingSnapshot`, which may contain the
/// union of router-known and unseen durable work after a failed write.
private var recoveryRouterSnapshot: Snapshot = [:]
/// The durable messages absent from MessageRouter's locked-wake snapshot
/// when recovery completed. Only this subset is unioned into authoritative
/// router saves before the recovery callback is claimed.
private var unseenRecoveredSnapshot: Snapshot = [:]
/// Covers the narrow launch race where protected data becomes available
/// after `load()` returned but before MessageRouter installs its handler.
private var unreportedRecoveredSnapshot: RecoveredSnapshot?
/// Invalidates recovery callbacks already queued onto the main actor when
/// panic wipe begins.
private var lifecycleGeneration: UInt64 = 0
#if os(iOS)
private var protectedDataObserver: NSObjectProtocol?
#endif
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
init(
keychain: KeychainManagerProtocol,
fileURL: URL? = nil,
readData: @escaping (URL) throws -> Data = { try Data(contentsOf: $0) },
writeData: @escaping (Data, URL, Data.WritingOptions) throws -> Void = {
try $0.write(to: $1, options: $2)
},
beforeRecoveryNotification: @escaping () -> Void = {}
) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
self.readData = readData
self.writeData = writeData
self.beforeRecoveryNotification = beforeRecoveryNotification
#if os(iOS)
protectedDataObserver = NotificationCenter.default.addObserver(
forName: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil,
queue: nil
) { [weak self] _ in
self?.retryDeferredLoad()
}
#endif
}
deinit {
#if os(iOS)
if let protectedDataObserver {
NotificationCenter.default.removeObserver(protectedDataObserver)
}
#endif
}
// 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 [:]
func load() -> Snapshot {
lock.lock()
defer { lock.unlock() }
if case .loaded = diskState {
return cachedSnapshot
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
// Once a deferred recovery has classified the durable baseline,
// `cachedSnapshot` is the only safe synchronous view. Re-reading via
// the generic load path would confuse its durable+router union with a
// fully router-known authoritative snapshot.
if unseenRecoveryPendingPersistence || recoveryDeliveryPending {
return cachedSnapshot
}
let wasDeferred = diskState == .deferred
switch readSnapshotLocked() {
case .loaded(let durable):
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshotIsAuthoritative
? (pendingSnapshot ?? [:])
: Self.merge(durable, pendingSnapshot ?? [:]))
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
// The recovery callback is driven by `retryDeferredLoad`; `load`
// itself returns the recovered value synchronously to its caller.
return cachedSnapshot
case .missing:
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
diskState = .loaded
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
case .deferred(let error):
diskState = .deferred
if !wasDeferred {
SecureLogger.warning("Outbox unavailable; deferring load until protected data is available: \(String(describing: error))", category: .session)
}
return pendingSnapshot ?? [:]
case .corrupt(let error):
diskState = .loaded
cachedSnapshot = applyingPendingRemovalsLocked(pendingSnapshot ?? [:])
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if pendingSnapshot != nil || !pendingRemovalMessageIDs.isEmpty {
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
return cachedSnapshot
}
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
func save(_ outbox: Snapshot) {
var recovered: RecoveredSnapshot?
lock.lock()
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
let flattened = applyingPendingRemovalsLocked(outbox.filter { !$0.value.isEmpty })
if recoveryStateWasPending {
// `save` is the router's complete current view. Keep it separate
// from the durable union retained for disk retry.
recoveryRouterSnapshot = flattened
}
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
switch diskState {
case .loaded:
cachedSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
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)
if !persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
// Retain the latest complete router snapshot. Because its
// durable baseline was already loaded, it replaces the older
// disk file after protected data returns (preserving removals).
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .unknown, .deferred:
let wasDeferred = diskState == .deferred
// A non-authoritative deferred snapshot means the initial cold
// load never completed. An authoritative deferred snapshot with
// no recovery state is merely an ordinary post-load write retry.
let isRecoveryAttempt = recoveryStateWasPending ||
(wasDeferred && !pendingSnapshotIsAuthoritative)
switch readSnapshotLocked() {
case .loaded(let durable):
// `save` before a successful `load` is not authoritative over
// an unreadable snapshot. Union by message ID so neither the
// durable queue nor work accepted during the locked wake is
// lost.
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: flattened))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? flattened : Self.merge(durable, flattened))
cachedSnapshot = applyingPendingRemovalsLocked(merged)
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(cachedSnapshot) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: cachedSnapshot,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = cachedSnapshot
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
// `save` receives MessageRouter's complete current in-memory
// snapshot. Replace prior locked-wake state so delivery acks
// and expiry removals become tombstones for that state; only
// the still-unknown durable snapshot is unioned on recovery.
pendingSnapshot = applyingPendingRemovalsLocked(
recoveryStateWasPending
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
diskState = .deferred
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox: \(error)", category: .session)
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = flattened
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(
preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: flattened
)
cachedSnapshot = merged
diskState = .loaded
if persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
}
/// Installs the router-side merge hook used when a cold, locked launch
/// initially received an empty snapshot and protected data later becomes
/// readable.
func setRecoveryHandler(_ handler: @escaping @MainActor (Snapshot) -> Void) {
lock.lock()
recoveryHandler = handler
let unreported = unreportedRecoveredSnapshot
unreportedRecoveredSnapshot = nil
lock.unlock()
if let unreported {
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: unreported.generation) else { return }
handler(latest)
}
}
}
/// Records an ack even when a locked cold-load has not revealed the
/// matching durable message yet. The next `save`/recovery applies this
/// tombstone before writing or notifying MessageRouter.
func recordRemoval(messageID: String) {
lock.lock()
pendingRemovalMessageIDs.insert(messageID)
cachedSnapshot = Self.removing([messageID], from: cachedSnapshot)
unseenRecoveredSnapshot = Self.removing([messageID], from: unseenRecoveredSnapshot)
recoveryRouterSnapshot = Self.removing([messageID], from: recoveryRouterSnapshot)
if let pendingSnapshot {
self.pendingSnapshot = Self.removing([messageID], from: pendingSnapshot)
}
lock.unlock()
}
/// Retries a deferred protected-data load. The returned snapshot includes
/// both durable messages and any messages queued during the locked wake.
@discardableResult
func retryDeferredLoad() -> Snapshot? {
var recovered: RecoveredSnapshot?
lock.lock()
guard diskState == .deferred else {
lock.unlock()
return nil
}
let recoveryWasPending = recoveryDeliveryPending
let unseenClassificationWasPending = unseenRecoveryPendingPersistence
let recoveryStateWasPending = recoveryWasPending || unseenClassificationWasPending
// `pendingSnapshotIsAuthoritative` alone means a normal write failed
// after the router had already loaded its baseline. It must retry, but
// must not masquerade as cold-load recovery or schedule a callback.
let isRecoveryAttempt = recoveryStateWasPending || !pendingSnapshotIsAuthoritative
switch readSnapshotLocked() {
case .loaded(let durable):
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
let newlyUnseen = recoveryStateWasPending
? unseenRecoveredSnapshot
: (isRecoveryAttempt
? applyingPendingRemovalsLocked(Self.excludingKnownMessages(from: durable, known: known))
: [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = newlyUnseen
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: (pendingSnapshotIsAuthoritative ? known : Self.merge(durable, known)))
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: newlyUnseen
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .missing:
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
case .deferred:
break
case .corrupt(let error):
SecureLogger.error("Failed to decode encrypted outbox after protected-data recovery: \(error)", category: .session)
let known = recoveryStateWasPending ? recoveryRouterSnapshot : (pendingSnapshot ?? [:])
if isRecoveryAttempt && !recoveryStateWasPending {
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = known
unseenRecoveryPendingPersistence = true
}
let preservesUnseenRecovery = recoveryStateWasPending || unseenRecoveryPendingPersistence
let merged = applyingPendingRemovalsLocked(preservesUnseenRecovery
? Self.merge(unseenRecoveredSnapshot, recoveryRouterSnapshot)
: known)
cachedSnapshot = merged
diskState = .loaded
if (pendingSnapshot == nil && pendingRemovalMessageIDs.isEmpty) ||
persistSnapshotAndClearRemovalsLocked(merged) {
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
if isRecoveryAttempt && !recoveryWasPending {
recovered = RecoveredSnapshot(
snapshot: merged,
generation: lifecycleGeneration,
unseenDurable: unseenRecoveredSnapshot
)
}
} else {
pendingSnapshot = merged
pendingSnapshotIsAuthoritative = true
diskState = .deferred
}
}
if let recovered {
unseenRecoveryPendingPersistence = false
recoveryDeliveryPending = true
unseenRecoveredSnapshot = recovered.unseenDurable
}
lock.unlock()
if let recovered {
beforeRecoveryNotification()
notifyRecovered(recovered.snapshot, generation: recovered.generation)
}
return recovered?.snapshot
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
lock.lock()
diskState = .loaded
cachedSnapshot = [:]
pendingSnapshot = nil
pendingSnapshotIsAuthoritative = false
pendingRemovalMessageIDs.removeAll()
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
lifecycleGeneration &+= 1
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
lock.unlock()
}
// 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)
switch readEncryptionKey() {
case .available(let key):
return key
case .missing:
break
case .invalid:
guard createIfMissing else { return nil }
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
case .unavailable:
return nil
}
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.
@@ -139,9 +616,222 @@ final class MessageOutboxStore {
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
// The protocol's generic save predates result-bearing writes. Verify
// the item before sealing a file: otherwise a locked/full Keychain
// could drop the key while we successfully write unrecoverable mail.
guard case .success(let stored) = keychain.loadWithResult(
key: Self.keychainKey,
service: Self.keychainService
), stored == data else {
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
SecureLogger.error("Outbox encryption key was not retained by Keychain", category: .session)
return nil
}
return key
}
private func readEncryptionKey() -> EncryptionKeyReadResult {
switch keychain.loadWithResult(key: Self.keychainKey, service: Self.keychainService) {
case .success(let data):
guard data.count == 32 else { return .invalid }
return .available(SymmetricKey(data: data))
case .itemNotFound:
return .missing
case .deviceLocked, .authenticationFailed:
return .unavailable(nil)
case .accessDenied:
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(errSecNotAvailable)))
case .otherError(let status):
return .unavailable(NSError(domain: NSOSStatusErrorDomain, code: Int(status)))
}
}
/// Must be called with `lock` held.
private func readSnapshotLocked() -> DiskReadResult {
guard let fileURL,
FileManager.default.fileExists(atPath: fileURL.path) else {
return .missing
}
let sealed: Data
do {
sealed = try readData(fileURL)
} catch {
return .deferred(error)
}
// A locked Keychain is transient; a genuine item-not-found next to an
// existing sealed file is permanent (notably after restoring onto a
// new device, because the key is ThisDeviceOnly). Remove that
// unrecoverable ciphertext before allowing a replacement key/file.
let key: SymmetricKey
switch readEncryptionKey() {
case .available(let availableKey):
key = availableKey
case .missing:
return discardOrphanedSnapshotLocked(reason: "encryption key is missing")
case .invalid:
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
return discardOrphanedSnapshotLocked(reason: "encryption key has an invalid length")
case .unavailable(let error):
return .deferred(error)
}
do {
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)
var outbox: Snapshot = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
Self.migrateFileProtectionIfNeeded(at: fileURL)
return .loaded(outbox)
} catch {
return .corrupt(error)
}
}
/// Must be called with `lock` held. A ciphertext whose ThisDeviceOnly key
/// is definitively absent can never become readable; removing it is safer
/// than deferring forever or overwriting it while pretending it loaded.
private func discardOrphanedSnapshotLocked(reason: String) -> DiskReadResult {
guard let fileURL else { return .missing }
do {
try FileManager.default.removeItem(at: fileURL)
SecureLogger.warning("Removed unrecoverable encrypted outbox because its \(reason)", category: .session)
return .missing
} catch {
SecureLogger.error("Could not remove unrecoverable encrypted outbox: \(error)", category: .session)
return .deferred(error)
}
}
/// Must be called with `lock` held.
@discardableResult
private func persistSnapshotLocked(_ snapshot: Snapshot) -> Bool {
guard let fileURL else { return true }
do {
if snapshot.isEmpty {
if FileManager.default.fileExists(atPath: fileURL.path) {
try FileManager.default.removeItem(at: fileURL)
}
return true
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return false
}
let keyed = Dictionary(uniqueKeysWithValues: snapshot.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(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try writeData(sealed, fileURL, options)
return true
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
return false
}
}
/// Must be called with `lock` held.
private func persistSnapshotAndClearRemovalsLocked(_ snapshot: Snapshot) -> Bool {
guard persistSnapshotLocked(snapshot) else { return false }
pendingRemovalMessageIDs.removeAll()
return true
}
/// Must be called with `lock` held.
private func applyingPendingRemovalsLocked(_ snapshot: Snapshot) -> Snapshot {
Self.removing(pendingRemovalMessageIDs, from: snapshot)
}
private static func removing(_ messageIDs: Set<String>, from snapshot: Snapshot) -> Snapshot {
guard !messageIDs.isEmpty else { return snapshot }
var filtered: Snapshot = [:]
for (peerID, queue) in snapshot {
let remaining = queue.filter { !messageIDs.contains($0.messageID) }
if !remaining.isEmpty { filtered[peerID] = remaining }
}
return filtered
}
private static func excludingKnownMessages(from durable: Snapshot, known: Snapshot) -> Snapshot {
let knownIDs = Set(known.values.flatMap { $0.map(\.messageID) })
return removing(knownIDs, from: durable)
}
private static func merge(_ durable: Snapshot, _ pending: Snapshot) -> Snapshot {
var merged = durable
for (peerID, pendingQueue) in pending {
var queue = merged[peerID] ?? []
for var candidate in pendingQueue {
if let index = queue.firstIndex(where: { $0.messageID == candidate.messageID }) {
candidate.sendAttempts = max(candidate.sendAttempts, queue[index].sendAttempts)
candidate.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = candidate
} else {
queue.append(candidate)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if !queue.isEmpty { merged[peerID] = queue }
}
return merged.filter { !$0.value.isEmpty }
}
private func notifyRecovered(_ snapshot: Snapshot, generation: UInt64) {
lock.lock()
guard lifecycleGeneration == generation else {
lock.unlock()
return
}
let handler = recoveryHandler
if handler == nil {
unreportedRecoveredSnapshot = RecoveredSnapshot(
snapshot: snapshot,
generation: generation,
unseenDurable: unseenRecoveredSnapshot
)
}
lock.unlock()
guard let handler else { return }
Task { @MainActor [weak self] in
guard let latest = self?.claimPendingRecovery(generation: generation) else { return }
handler(latest)
}
}
private func claimPendingRecovery(generation: UInt64) -> Snapshot? {
lock.lock()
defer { lock.unlock() }
guard lifecycleGeneration == generation, recoveryDeliveryPending else { return nil }
let latest = cachedSnapshot
recoveryDeliveryPending = false
unseenRecoveryPendingPersistence = false
unseenRecoveredSnapshot = [:]
recoveryRouterSnapshot = [:]
unreportedRecoveredSnapshot = nil
return latest
}
private static func migrateFileProtectionIfNeeded(at fileURL: URL) {
#if os(iOS)
do {
try FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
} catch {
SecureLogger.warning("Failed to migrate outbox file protection: \(error)", category: .session)
}
#endif
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
@@ -65,8 +65,11 @@ final class BridgeCourierService: ObservableObject {
var bridgeEnabled: (@MainActor () -> Bool)?
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event to the default (DM) relays.
var publishEvent: (@MainActor (NostrEvent) -> Void)?
/// Publishes a signed drop event directly to connected default (DM)
/// relays. Completion is true only after at least one relay explicitly
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
/// or merely "written to a socket".
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
/// (Re)opens the drop subscription for the given hex tags.
var openSubscription: (@MainActor ([String]) -> Void)?
var closeSubscription: (@MainActor () -> Void)?
@@ -76,14 +79,21 @@ final class BridgeCourierService: ObservableObject {
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
/// Seals content into a carry-only envelope for a recipient key.
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
/// Opens a drop addressed to us (tag verified inside).
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
/// Opens a drop addressed to us. True means the inner envelope was
/// delivered, deduplicated, or intentionally rejected after decryption;
/// false leaves the relay event retryable after a transient crypto/key
/// failure.
var openEnvelope: (@MainActor (CourierEnvelope) -> Bool)?
/// Hands a drop to a matching local peer as a directed courier packet.
/// Returns false when the handoff could not even be attempted (peer no
/// longer reachable), so the drop event stays retryable.
/// Returns true only when the transport accepted the packet onto a live
/// physical link or its link-specific backpressure queue. Stale
/// reachability and process-local directed spooling return false so the
/// drop event stays retryable.
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Bool)?
/// Held envelopes eligible for (re)publish, honoring the cooldown.
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
/// Commits a held envelope's cooldown after confirmed relay acceptance.
var markHeldEnvelopePublished: (@MainActor (CourierEnvelope) -> Void)?
/// Timer injection for tests; nil arms a real `Task`.
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
@@ -91,7 +101,11 @@ final class BridgeCourierService: ObservableObject {
private(set) var myTagsHex: Set<String> = []
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
private(set) var pendingDrops: [(
envelope: CourierEnvelope,
dedupKey: String?,
operationID: UUID?
)] = []
/// Message IDs already published as drops (sender-side dedup) and drop
/// event IDs already handled (multi-relay dedup). Both persist across
/// relaunches: relays hold drops for the full 24h NIP-40 window and the
@@ -104,7 +118,27 @@ final class BridgeCourierService: ObservableObject {
private var subscriptionOpen = false
private var lastSubscribedTags: Set<String> = []
private var refreshTimerArmed = false
private var announceRefreshTimerArmed = false
private var lastAnnounceRefresh = Date.distantPast
private struct ActiveDropOperation {
let id: UUID
let completion: @MainActor (Bool) -> Void
}
/// Sender operations queued locally or awaiting relay confirmation.
/// The per-attempt ID prevents a stale pre-wipe callback from completing
/// a newer attempt for the same message.
private var activeDropOperations: [String: ActiveDropOperation] = [:]
/// Held-envelope publishes have no sender message ID, but still need an
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
/// must not mint duplicate relay events for the same opaque envelope.
private var heldDropOperations: [Data: UUID] = [:]
/// Deterministically invalid envelopes are suppressed for this process,
/// but never persisted as if a relay accepted them. Bound and age them so
/// rotating oversize IDs cannot grow process memory forever.
private var rejectedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds
)
private let now: () -> Date
private let dedupStore: BridgeDropDedupStore
@@ -156,23 +190,23 @@ final class BridgeCourierService: ObservableObject {
}
}
/// Writes the dedup record now. Sender keys still sitting in the
/// in-memory `pendingDrops` queue are excluded: their drop is not durable
/// until it actually reaches a relay, and persisting the key early would
/// turn "app killed before relays connected" into a silent 24h blackhole
/// (the relaunch loses the queued drop but the persisted key blocks every
/// re-deposit). `flushPendingDrops` re-persists once they publish.
/// Writes the dedup record now. `publishedDropKeys` contains only drops
/// a relay explicitly accepted; queued and in-flight keys
/// live in `activeDropOperations` and are intentionally process-local.
func flushDedupSnapshot() {
let pendingKeys = Set(pendingDrops.compactMap(\.dedupKey))
dedupStore.save(BridgeDropDedupStore.Snapshot(
publishedDropKeys: publishedDropKeys.entries.filter { !pendingKeys.contains($0.key) },
publishedDropKeys: publishedDropKeys.entries,
seenDropEventIDs: seenDropEventIDs.entries
))
}
/// Panic wipe: forget queued drops and the persisted dedup record.
func wipe() {
pendingDrops.removeAll()
cancelActivePublishes()
rejectedDropKeys = ExpiringIDSet(
capacity: Limits.maxTrackedIDs,
lifetime: CourierEnvelope.maxLifetimeSeconds
)
publishedDropKeys = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
seenDropEventIDs = ExpiringIDSet(capacity: Limits.maxTrackedIDs, lifetime: CourierEnvelope.maxLifetimeSeconds)
dedupStore.wipe()
@@ -182,32 +216,41 @@ final class BridgeCourierService: ObservableObject {
/// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Returns true when a fresh drop
/// was sealed (published now or queued for the next relay connection)
/// the router marks the message "carried" so the sender sees progress.
@discardableResult
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
guard bridgeEnabled?() ?? false else { return false }
guard !publishedDropKeys.contains(messageID, now: now()) else { return false }
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
/// deposits; idempotent per message ID. Completion becomes true only
/// after a real relay acceptance arrives, which is when the router may
/// show "carried".
func depositDrop(
content: String,
messageID: String,
recipientNoiseKey: Data,
completion: @escaping @MainActor (Bool) -> Void = { _ in }
) {
guard bridgeEnabled?() ?? false else {
completion(false)
return
}
guard !publishedDropKeys.contains(messageID, now: now()),
activeDropOperations[messageID] == nil,
!rejectedDropKeys.contains(messageID, now: now()) else {
completion(false)
return
}
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else {
completion(false)
return
}
// An envelope that can't encode within the drop size caps fails the
// same way on every attempt (size is a function of the content, not
// of the sealing); consume the dedup slot so the retry sweep stops
// re-running Noise sealing on a drop that can never ship.
// of the sealing); suppress it in-memory so the retry sweep does not
// churn, but never persist it as a published drop.
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
publishedDropKeys.insert(messageID, now: now())
persistDedup()
return false
rejectedDropKeys.insert(messageID, now: now())
completion(false)
return
}
// Only consume the sender-side dedup slot once the drop is durably
// accepted (published, or safely queued for the next relay
// connection). If the compose fails, leave the slot open so the
// router's retry sweep can attempt a fresh deposit rather than
// marking the message "carried" and blocking retries forever.
guard publishDrop(envelope, messageID: messageID) else { return false }
publishedDropKeys.insert(messageID, now: now())
persistDedup()
return true
let operationID = UUID()
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion)
publishDrop(envelope, messageID: messageID, operationID: operationID)
}
/// Publishes held envelopes (mail we carry for others) as drops,
@@ -215,32 +258,59 @@ final class BridgeCourierService: ObservableObject {
func publishHeldEnvelopes() {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
publishDrop(envelope)
let key = envelope.ciphertext
guard heldDropOperations[key] == nil else { continue }
let operationID = UUID()
heldDropOperations[key] = operationID
publishDrop(envelope) { [weak self] succeeded in
guard let self, self.heldDropOperations[key] == operationID else { return }
self.heldDropOperations.removeValue(forKey: key)
if succeeded {
self.markHeldEnvelopePublished?(envelope)
}
}
}
}
/// Publishes a drop, or queues it when relays are down. `messageID` is the
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
/// it rides the pending queue so an evicted drop can release its slot.
/// Returns false only when the drop could not be made durable (bad
/// encode/expired/compose failure) so callers can keep it retryable.
@discardableResult
private func publishDrop(_ envelope: CourierEnvelope, messageID: String? = nil) -> Bool {
/// it rides the pending queue so an evicted or failed drop can release its
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
private func publishDrop(
_ envelope: CourierEnvelope,
messageID: String? = nil,
operationID: UUID? = nil,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) {
guard let encoded = envelope.encode(),
encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else { return false }
!envelope.isExpired else {
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
guard relaysConnected?() ?? false else {
pendingDrops.append((envelope, messageID))
// Held mail remains in CourierStore and has no sender operation to
// recover after an in-memory queue loss. Leave its cooldown unset
// and let the next connected refresh offer it again.
guard messageID != nil else {
untrackedCompletion?(false)
return
}
pendingDrops.append((envelope, messageID, operationID))
while pendingDrops.count > Limits.maxPendingDrops {
let evicted = pendingDrops.removeFirst()
// The oldest queued drop is being dropped before it ever
// published; release its dedup slot so it stays retryable.
if let key = evicted.dedupKey {
publishedDropKeys.remove(key)
persistDedup()
}
finishPublish(
messageID: evicted.dedupKey,
operationID: evicted.operationID,
succeeded: false
)
}
return true
return
}
guard let identity = try? NostrIdentity.generate(),
let event = try? NostrProtocol.createCourierDropEvent(
@@ -250,10 +320,62 @@ final class BridgeCourierService: ObservableObject {
senderIdentity: identity
) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
return false
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
publishEvent?(event)
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
guard let publishEvent else {
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: false,
untrackedCompletion: untrackedCompletion
)
return
}
publishEvent(event) { [weak self] succeeded in
guard let self else { return }
guard self.finishPublish(
messageID: messageID,
operationID: operationID,
succeeded: succeeded,
untrackedCompletion: untrackedCompletion
) else { return }
if succeeded {
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))", category: .session)
} else {
SecureLogger.warning("📦🌉 No relay accepted courier drop", category: .session)
}
}
}
@discardableResult
private func finishPublish(
messageID: String?,
operationID: UUID?,
succeeded: Bool,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) -> Bool {
guard let messageID else {
untrackedCompletion?(succeeded)
return true
}
// Missing/mismatched means this callback was duplicated, invalidated
// by panic wipe, or belongs to an older attempt for the same key.
guard let operationID,
let operation = activeDropOperations[messageID],
operation.id == operationID else { return false }
activeDropOperations.removeValue(forKey: messageID)
if succeeded {
publishedDropKeys.insert(messageID, now: now())
persistDedup()
}
operation.completion(succeeded)
return true
}
@@ -262,16 +384,13 @@ final class BridgeCourierService: ObservableObject {
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
let queued = pendingDrops
pendingDrops.removeAll()
for item in queued where !publishDrop(item.envelope, messageID: item.dedupKey) {
// Compose failed with relays up: release the slot so the router's
// retry sweep can attempt a fresh deposit.
if let key = item.dedupKey {
publishedDropKeys.remove(key)
}
for item in queued {
publishDrop(
item.envelope,
messageID: item.dedupKey,
operationID: item.operationID
)
}
// Flushed keys just became durable (published, so no longer excluded
// as pending) or were released above; either way the record changed.
persistDedup()
}
// MARK: - Subscription (recipient + gateway watch)
@@ -281,7 +400,15 @@ final class BridgeCourierService: ObservableObject {
/// (tags rotate daily); idempotent.
func refresh() {
armRefreshTimerIfNeeded()
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
guard bridgeEnabled?() ?? false else {
cancelActivePublishes()
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
}
return
}
guard relaysConnected?() ?? false else {
if subscriptionOpen {
closeSubscription?()
subscriptionOpen = false
@@ -323,11 +450,37 @@ final class BridgeCourierService: ObservableObject {
/// Announce-driven refresh, debounced a newly verified peer should be
/// watched promptly, but announce storms must not thrash subscriptions.
/// Calls inside the window coalesce into one trailing refresh so peers
/// learned after the leading edge are not omitted until the 30-minute
/// periodic timer.
func refreshAfterVerifiedAnnounce() {
guard bridgeEnabled?() ?? false else { return }
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
lastAnnounceRefresh = now()
refresh()
let date = now()
let elapsed = date.timeIntervalSince(lastAnnounceRefresh)
if elapsed >= Limits.announceRefreshDebounceSeconds {
lastAnnounceRefresh = date
refresh()
return
}
guard !announceRefreshTimerArmed else { return }
announceRefreshTimerArmed = true
let delay = max(0, Limits.announceRefreshDebounceSeconds - elapsed)
let fire: @MainActor () -> Void = { [weak self] in
guard let self else { return }
self.announceRefreshTimerArmed = false
guard self.bridgeEnabled?() ?? false else { return }
self.lastAnnounceRefresh = self.now()
self.refresh()
}
if let scheduleTimer {
scheduleTimer(delay, fire)
} else {
Task { @MainActor in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
fire()
}
}
}
private func armRefreshTimerIfNeeded() {
@@ -355,8 +508,10 @@ final class BridgeCourierService: ObservableObject {
func handleDropEvent(_ event: NostrEvent) {
guard bridgeEnabled?() ?? false else { return }
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
guard seenDropEventIDs.insert(event.id, now: now()) else { return }
persistDedup()
// A resubscribe can still deliver an event from the old watch set.
// Do not durably consume it until it actually belongs to us/current
// local peer and is opened or accepted for physical delivery.
guard !seenDropEventIDs.contains(event.id, now: now()) else { return }
guard let data = Data(base64Encoded: event.content),
data.count <= Limits.maxDropEnvelopeBytes,
let envelope = CourierEnvelope.decode(data),
@@ -371,17 +526,16 @@ final class BridgeCourierService: ObservableObject {
if myTagsHex.contains(tagHex) {
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
openEnvelope?(envelope)
if openEnvelope?(envelope) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
return
}
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))", category: .session)
if deliverToPeer?(envelope, match.peerID) != true {
// The best-effort handoff never left this device (the peer
// walked away between the relay fetch and the mesh send).
// Release the seen slot so a relaunch or backlog redelivery
// retries a single-gateway island has no other carrier.
seenDropEventIDs.remove(event.id)
if deliverToPeer?(envelope, match.peerID) == true {
seenDropEventIDs.insert(event.id, now: now())
persistDedup()
}
}
@@ -389,6 +543,19 @@ final class BridgeCourierService: ObservableObject {
// MARK: - Helpers
/// Cancels queued and in-flight sender operations after bridge disable or
/// panic wipe. Invalidate first, then resolve false so callback re-entry
/// cannot be mistaken for an active operation; late relay callbacks no-op.
private func cancelActivePublishes() {
let invalidated = activeDropOperations.values.map(\.completion)
pendingDrops.removeAll()
activeDropOperations.removeAll()
// Untracked held publishes are invalidated too. Their late relay
// callbacks compare operation IDs and no-op after this reset.
heldDropOperations.removeAll()
invalidated.forEach { $0(false) }
}
/// A fresh random Nostr identity for signing one drop. Delegates to the
/// canonical generator (Schnorr key that can't fail validity) instead of
/// hand-rolling SecRandom + retry.
+241 -53
View File
@@ -47,11 +47,15 @@ import Foundation
/// already holds the radio copy (`isMessageSeenLocally` on the event's
/// mesh message ID) remote islands' traffic is the only thing worth
/// airtime.
/// Receivers additionally dedup by timeline message ID: the wire carries no
/// public message ID, so every device derives the same content-stable one
/// (`MeshMessageIdentity`) from the origin coordinates in the event's `m`
/// tag recomputed locally, never trusted and the store's insert-by-ID
/// absorbs radio/bridge duplicates in either arrival order.
/// Receivers key bridge rows by the signed Nostr event ID. The event's `m` tag
/// is only a radio-copy hint: its mesh sender/timestamp fields are public and
/// cannot authenticate the event signer, so letting them own the timeline ID
/// would allow a different signer to front-run the genuine event's dedup slot.
/// When the radio copy is already present the hint avoids duplicate rendering
/// and downlink airtime. If the bridge copy wins the race, a later
/// authenticated radio copy replaces every bridge row that claimed the same
/// hint; the untrusted hint can therefore merge a duplicate but can never
/// suppress the radio-authenticated origin.
///
/// All dependencies are closure-injected (repo convention) so the policy
/// layer is unit-testable without relays, radios, or CoreLocation.
@@ -71,11 +75,25 @@ final class BridgeService: ObservableObject {
static let maxEventAgeSeconds: TimeInterval = 15 * 60
/// Bounded loop-prevention ID caches (oldest evicted).
static let maxTrackedEventIDs = 512
/// Keep radio-replacement aliases for every bridge row that can still
/// be visible in the bounded mesh timeline. Retiring an alias earlier
/// would let valid high-volume ingress delete otherwise visible
/// history merely to preserve the radio-wins invariant.
static let maxTrackedRadioAliases = TransportConfig.meshTimelineCap
/// Presence heartbeat cadence while the bridge is active.
static let presenceIntervalSeconds: TimeInterval = 4 * 60
/// A rendezvous participant counts toward "via bridge" for this long
/// after their last event.
static let participantFreshnessSeconds: TimeInterval = 10 * 60
/// Relay ingress is adversarial: bound both accepted work and the
/// people-sheet state even when an attacker rotates signing keys.
static let inboundEventsPerMinute = 600
static let inboundEventsPerMinutePerSigner = 120
/// Cheap pre-crypto gate for both valid and invalid ingress. Without
/// it, invalid carrier events could force unbounded Schnorr work while
/// never reaching the accepted-event limiter below.
static let signatureVerificationAttemptsPerMinute = 720
static let maxParticipants = 128
/// Content cap, matching the public-message pipeline's own limit.
static let maxContentBytes = 16_000
/// Geohash-cell precision of the rendezvous (neighborhood, ~1.2 km).
@@ -91,7 +109,11 @@ final class BridgeService: ObservableObject {
/// A validated rendezvous message ready for the timeline.
struct InboundBridgeMessage {
let messageID: String
/// Unauthenticated mesh coordinates can only be used to notice that a
/// verified radio copy is already present; they never own bridge dedup.
let radioMessageIDHint: String?
let senderNickname: String
let participantNickname: String?
let senderPubkey: String
let content: String
let timestamp: Date
@@ -116,10 +138,9 @@ final class BridgeService: ObservableObject {
/// The user toggle. While true this device publishes its own public mesh
/// messages to the rendezvous and subscribes to it when online.
@Published private(set) var isEnabled: Bool
/// Distinct remote rendezvous participants seen within the freshness
/// window. Approximate by design: local participants are subtracted by
/// matching their events' mesh message IDs against the local timeline,
/// which cannot attribute silent (presence-only) local peers.
/// Distinct rendezvous participants seen within the freshness window.
/// Radio-copy hints never alter signer locality: their public coordinates
/// can suppress a duplicate row but cannot authenticate a Nostr signer.
@Published private(set) var bridgedPeerCount: Int = 0
/// The people behind the count, newest activity first.
@Published private(set) var bridgedParticipants: [BridgedParticipant] = []
@@ -159,6 +180,13 @@ final class BridgeService: ObservableObject {
var broadcastToMesh: (@MainActor (Data) -> Void)?
/// Delivers a validated inbound bridge message to the mesh timeline.
var injectInbound: (@MainActor (InboundBridgeMessage) -> Void)?
/// Removes a previously injected bridge row when an authenticated radio
/// copy arrives later. The UI hook also discards a not-yet-flushed row.
var removeInjectedInbound: (@MainActor (String) -> Void)?
/// Exact liveness check for a bridge row in either the pending UI pipeline
/// or bounded conversation store. Alias pruning may discard proof only
/// after the corresponding row is already gone.
var isInjectedInboundPresent: (@MainActor (String) -> Bool)?
/// True when the mesh timeline already holds this message ID (the radio
/// copy) used to skip pointless downlink airtime.
var isMessageSeenLocally: (@MainActor (String) -> Bool)?
@@ -185,32 +213,56 @@ final class BridgeService: ObservableObject {
private var rebroadcastEventIDs: BoundedIDSet
/// Timeline message IDs already injected (either arrival path).
private var injectedMessageIDs: BoundedIDSet
/// Signed relay/carrier events already accepted. Kept separate from the
/// loop caches so a mesh arrival can still mark loop suppression even when
/// the relay copy won the race.
private var receivedEventIDs: BoundedIDSet
/// Authenticated radio IDs observed this session. These close the
/// bridge-first race even before the UI pipeline flushes its radio row.
private var observedRadioMessageIDs: BoundedIDSet
/// event ID -> untrusted radio hint. Event IDs own bridge
/// dedup; this bounded reverse index is used only to replace bridge rows
/// after the genuine radio packet has authenticated successfully.
private var injectedRadioAliases: [String: String] = [:]
private var injectedRadioAliasOrder: [String] = []
/// Cells the rendezvous subscription covers (own + neighbor ring).
private(set) var subscribedCells: Set<String> = []
private(set) var queuedUplinks: [QueuedUplink] = []
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
private var downlinkSendTimes: [Date] = []
private var inboundEventTimes: [Date] = []
private var inboundEventTimesBySigner: [String: [Date]] = [:]
private var signatureVerificationTokens = Double(Limits.signatureVerificationAttemptsPerMinute)
private var signatureVerificationLastRefillAt: Date?
private var pendingDownlinks: [(event: NostrEvent, cell: String)] = []
private var downlinkDrainScheduled = false
private var presenceTimerArmed = false
private var lastPresenceAt = Date.distantPast
/// pubkey -> (lastSeen, attributed-to-local-island, last known nickname).
private var participants: [String: (lastSeen: Date, isLocal: Bool, nickname: String?)] = [:]
/// pubkey -> (lastSeen, last known nickname).
private var participants: [String: (lastSeen: Date, nickname: String?)] = [:]
private let defaults: UserDefaults
private let now: () -> Date
private let verifyEventSignature: (NostrEvent) -> Bool
private static let enabledKey = "bridge.userEnabled"
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
init(
defaults: UserDefaults = .standard,
now: @escaping () -> Date = Date.init,
verifyEventSignature: @escaping (NostrEvent) -> Bool = { $0.isValidSignature() }
) {
self.defaults = defaults
self.now = now
self.verifyEventSignature = verifyEventSignature
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.injectedMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.receivedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
self.observedRadioMessageIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
}
// MARK: - Toggle & lifecycle
@@ -223,6 +275,8 @@ final class BridgeService: ObservableObject {
queuedUplinks.removeAll()
pendingDownlinks.removeAll()
uplinkDepositTimes.removeAll()
inboundEventTimes.removeAll()
inboundEventTimesBySigner.removeAll()
participants.removeAll()
bridgedPeerCount = 0
bridgedParticipants = []
@@ -301,11 +355,6 @@ final class BridgeService: ObservableObject {
guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
guard content.utf8.count <= Limits.maxContentBytes else { return }
let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp)
let stableID = MeshMessageIdentity.stableID(
senderIDHex: senderPeerID.id,
timestampMs: timestampMs,
content: content
)
guard let identity = try? deriveIdentity?(cell),
let event = try? NostrProtocol.createBridgeMeshEvent(
content: content,
@@ -319,7 +368,7 @@ final class BridgeService: ObservableObject {
return
}
publishedEventIDs.insert(event.id)
injectedMessageIDs.insert(stableID) // our own timeline already has it
injectedMessageIDs.insert(event.id) // our own timeline already has it
if relaysConnected?() ?? false {
publishToRelays?(event, cell)
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
@@ -388,7 +437,6 @@ final class BridgeService: ObservableObject {
subscribedCells.contains(cell) else {
return
}
guard let kind = classify(event, cell: cell) else { return }
// Events we published come back from our own subscription; they are
// presence-neutral (we never count ourselves) and never re-injected
// or rebroadcast. Two layers: the published-ID cache (this session)
@@ -401,18 +449,23 @@ final class BridgeService: ObservableObject {
publishedEventIDs.insert(event.id) // never downlink it either
return
}
guard event.isValidSignature() else { return }
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else { return }
guard receivedEventIDs.insert(event.id), allowInboundEvent(from: event.pubkey) else { return }
guard let kind = classify(event, cell: cell) else { return }
switch kind {
case .presence:
recordParticipant(event.pubkey, isLocal: false, nickname: nil)
recordParticipant(event.pubkey, nickname: nil)
case .message(let message):
let isLocalRadioCopy = isMessageSeenLocally?(message.messageID) ?? false
let isLocalRadioCopy = message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false
if isLocalRadioCopy {
SecureLogger.debug("🌉 Bridge: radio copy of \(message.messageID.prefix(8))… already present; sender counted as local", category: .session)
// The public m-tag proves only that identical radio content is
// already present, not that this Nostr signer authored it.
// Skip the duplicate row without changing signer locality.
SecureLogger.debug("🌉 Bridge: authenticated radio copy already present; bridge alias skipped", category: .session)
} else if inject(message) {
recordParticipant(event.pubkey, nickname: message.participantNickname)
}
recordParticipant(event.pubkey, isLocal: isLocalRadioCopy, nickname: message.senderNickname)
inject(message)
// Serving duty: carry remote islands' messages onto the radio for
// mesh-only peers. Local-origin events are skipped the island
// already heard them (loop rule 3). The drain is jitter-delayed:
@@ -431,6 +484,33 @@ final class BridgeService: ObservableObject {
}
}
/// Called only after the BLE public-message signature has authenticated.
/// A bridge hint is not trusted enough to drop this radio row. Instead,
/// the radio row wins and any earlier bridge aliases are removed, which
/// gives both arrival orders one timeline row without restoring the
/// origin-spoof vulnerability.
func handleAuthenticatedRadioMessage(messageID: String) {
guard !messageID.isEmpty else { return }
observedRadioMessageIDs.insert(messageID)
let matching = injectedRadioAliases.filter { $0.value == messageID }
guard !matching.isEmpty else { return }
let eventIDs = Set(matching.keys)
for eventID in matching.keys {
removeInjectedInbound?(eventID)
injectedRadioAliases.removeValue(forKey: eventID)
}
injectedRadioAliasOrder.removeAll { eventIDs.contains($0) }
// A relay event can already be waiting inside the multi-gateway jitter
// window. Remove it now so it cannot consume BLE airtime after the
// authenticated radio packet proved this island already has a copy.
pendingDownlinks.removeAll { item in
guard case .message(let message)? = classify(item.event, cell: item.cell) else { return false }
return message.radioMessageIDHint == messageID
}
}
// MARK: - Mesh carrier ingress (both roles)
/// Entry point for received `nostrCarrier` packets with bridge
@@ -467,7 +547,7 @@ final class BridgeService: ObservableObject {
SecureLogger.debug("🌉 Bridge: rate-limited deposit from \(depositor.id.prefix(8))", category: .session)
return
}
guard event.isValidSignature() else {
guard allowSignatureVerificationAttempt(), verifyEventSignature(event) else {
SecureLogger.debug("🌉 Bridge: rejected deposit from \(depositor.id.prefix(8))… (bad signature)", category: .security)
return
}
@@ -523,6 +603,52 @@ final class BridgeService: ObservableObject {
return true
}
/// Bounds relay/carrier work independently of the downlink airtime budget.
/// A valid signature proves control of one key, not that the sender is
/// entitled to unbounded main-actor state or CPU.
private func allowInboundEvent(from signer: String) -> Bool {
let date = now()
let cutoff = date.addingTimeInterval(-60)
inboundEventTimes.removeAll { $0 < cutoff }
guard inboundEventTimes.count < Limits.inboundEventsPerMinute else { return false }
var signerTimes = inboundEventTimesBySigner[signer, default: []]
signerTimes.removeAll { $0 < cutoff }
guard signerTimes.count < Limits.inboundEventsPerMinutePerSigner else {
inboundEventTimesBySigner[signer] = signerTimes
return false
}
inboundEventTimes.append(date)
signerTimes.append(date)
inboundEventTimesBySigner[signer] = signerTimes
if inboundEventTimesBySigner.count > Limits.maxTrackedEventIDs {
inboundEventTimesBySigner = inboundEventTimesBySigner.filter { entry in
entry.value.contains { $0 >= cutoff }
}
}
return true
}
/// Bounds expensive signature checks before signer identity is trusted.
/// Kept independent from accepted-event accounting so invalid events do
/// not create signer state or poison any dedup cache.
private func allowSignatureVerificationAttempt() -> Bool {
let date = now()
if let lastRefillAt = signatureVerificationLastRefillAt {
let elapsed = max(0, date.timeIntervalSince(lastRefillAt))
let refillPerSecond = Double(Limits.signatureVerificationAttemptsPerMinute) / 60
signatureVerificationTokens = min(
Double(Limits.signatureVerificationAttemptsPerMinute),
signatureVerificationTokens + elapsed * refillPerSecond
)
}
signatureVerificationLastRefillAt = date
guard signatureVerificationTokens >= 1 else { return false }
signatureVerificationTokens -= 1
return true
}
// MARK: - Downlink (gateway role: internet -> mesh)
private func drainPendingDownlinks() {
@@ -533,9 +659,15 @@ final class BridgeService: ObservableObject {
let (event, cell) = pendingDownlinks.removeFirst()
guard isFresh(event) else { continue }
// Suppression recheck at send time: another gateway may have
// broadcast this event during our jitter holdoff.
// broadcast this event, or the authenticated radio copy may have
// arrived, during our jitter holdoff.
guard !meshBroadcastEventIDs.contains(event.id),
!rebroadcastEventIDs.contains(event.id) else { continue }
if case .message(let message)? = classify(event, cell: cell),
let radioMessageID = message.radioMessageIDHint,
radioCopyAlreadyPresent(radioMessageID) {
continue
}
guard let carrier = NostrCarrierPacket(direction: .fromBridge, geohash: cell, event: event),
let payload = carrier.encode() else { continue }
broadcastToMesh?(payload)
@@ -584,36 +716,90 @@ final class BridgeService: ObservableObject {
guard let event = structurallyValidEvent(from: carrier),
!publishedEventIDs.contains(event.id),
!isOwnRendezvousEvent(event, cell: carrier.geohash),
event.isValidSignature() else {
allowSignatureVerificationAttempt(),
verifyEventSignature(event) else {
return
}
// Mark after verification (a forged copy must not poison the cache),
// and use the marking as multi-path dedup.
guard meshBroadcastEventIDs.insert(event.id) else { return }
// even when the relay copy won the injection race: pending gateway
// downlinks consult this cache and must stand down.
let firstMeshArrival = meshBroadcastEventIDs.insert(event.id)
guard firstMeshArrival,
receivedEventIDs.insert(event.id),
allowInboundEvent(from: event.pubkey) else { return }
guard case .message(let message)? = classify(event, cell: carrier.geohash) else {
return
}
recordParticipant(event.pubkey, isLocal: false, nickname: message.senderNickname)
inject(message)
if inject(message) {
recordParticipant(event.pubkey, nickname: message.participantNickname)
}
}
// MARK: - Injection & participants
private func inject(_ message: InboundBridgeMessage) {
guard injectedMessageIDs.insert(message.messageID) else { return }
guard !(isMessageSeenLocally?(message.messageID) ?? false) else { return }
@discardableResult
private func inject(_ message: InboundBridgeMessage) -> Bool {
guard injectedMessageIDs.insert(message.messageID) else { return false }
guard !(message.radioMessageIDHint.map(radioCopyAlreadyPresent) ?? false) else {
return false
}
SecureLogger.info("🌉 Bridge: injected bridged message \(message.messageID.prefix(8))… from \(message.senderNickname)", category: .session)
injectInbound?(message)
if let radioMessageID = message.radioMessageIDHint {
recordRadioAlias(
eventID: message.messageID,
radioMessageID: radioMessageID
)
}
return true
}
private func recordParticipant(_ pubkey: String, isLocal: Bool, nickname: String?) {
private func radioCopyAlreadyPresent(_ messageID: String) -> Bool {
observedRadioMessageIDs.contains(messageID) || (isMessageSeenLocally?(messageID) ?? false)
}
private func recordRadioAlias(
eventID: String,
radioMessageID: String
) {
guard injectedRadioAliases[eventID] == nil else { return }
injectedRadioAliases[eventID] = radioMessageID
injectedRadioAliasOrder.append(eventID)
guard injectedRadioAliasOrder.count > Limits.maxTrackedRadioAliases,
let isInjectedInboundPresent else { return }
// Arrival order and signed event timestamps are independent. The
// conversation cap trims by timestamp, so blindly evicting the oldest
// alias could discard the proof for a still-visible row and then
// actively delete that history. Prune only aliases whose exact row is
// already absent; temporary overflow is safer than losing radio-wins
// reconciliation for any visible bridge message.
var overflow = injectedRadioAliasOrder.count - Limits.maxTrackedRadioAliases
var retained: [String] = []
retained.reserveCapacity(injectedRadioAliasOrder.count)
for candidate in injectedRadioAliasOrder {
if overflow > 0, !isInjectedInboundPresent(candidate) {
injectedRadioAliases.removeValue(forKey: candidate)
overflow -= 1
} else {
retained.append(candidate)
}
}
injectedRadioAliasOrder = retained
}
private func recordParticipant(_ pubkey: String, nickname: String?) {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
participants = participants.filter { $0.value.lastSeen >= cutoff }
if participants[pubkey] == nil, participants.count >= Limits.maxParticipants,
let oldest = participants.min(by: { $0.value.lastSeen < $1.value.lastSeen })?.key {
participants.removeValue(forKey: oldest)
}
let previous = participants[pubkey]
// Local attribution is sticky: one radio-confirmed message marks the
// pubkey as an islander for as long as they stay fresh. Presence
// events carry no nickname, so a known name is never forgotten.
// Presence events carry no nickname, so a known name is never
// forgotten. Radio hints deliberately do not mutate this record.
participants[pubkey] = (
lastSeen: now(),
isLocal: (previous?.isLocal ?? false) || isLocal,
nickname: nickname?.trimmedOrNilIfEmpty ?? previous?.nickname
)
recomputeBridgedCount()
@@ -628,7 +814,7 @@ final class BridgeService: ObservableObject {
private func recomputeBridgedCount() {
let cutoff = now().addingTimeInterval(-Limits.participantFreshnessSeconds)
let visible = participants
.filter { $0.value.lastSeen >= cutoff && !$0.value.isLocal }
.filter { $0.value.lastSeen >= cutoff }
.map { BridgedParticipant(pubkey: $0.key, nickname: $0.value.nickname, lastSeen: $0.value.lastSeen) }
.sorted { $0.lastSeen > $1.lastSeen }
if visible.count != bridgedPeerCount {
@@ -660,28 +846,30 @@ final class BridgeService: ObservableObject {
let content = event.content
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1]
// Recompute never trust the mesh message ID: the `m` tag is
// `[stable ID, sender ID, wire timestamp ms]`. Element 1 exists
// for v1.7.0 parsers; we ignore it and derive the ID from the
// origin coordinates (elements 2-3) plus the event's own content,
// so a forged tag cannot bind a chosen ID to different content
// (see `MeshMessageIdentity` for the exact property and its
// limits).
// The `m` tag is `[stable ID, sender ID, wire timestamp ms]` for
// radio/bridge duplicate detection. Those coordinates are public
// and not cryptographically bound to this Nostr signer, so they
// are never allowed to own bridge dedup. A copied tag can at most
// make the attacker's own event look like a radio duplicate; it
// cannot reserve the genuine signed event's timeline ID.
let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })
let messageID: String
let radioMessageIDHint: String?
if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit),
let timestampMs = UInt64(m[3]) {
messageID = MeshMessageIdentity.stableID(
radioMessageIDHint = MeshMessageIdentity.stableID(
senderIDHex: m[2],
timestampMs: timestampMs,
content: content
)
} else {
messageID = event.id // old-format or absent tag
radioMessageIDHint = nil
}
let baseNickname = nickname?.trimmedOrNilIfEmpty ?? "anon"
return .message(InboundBridgeMessage(
messageID: messageID,
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))",
messageID: event.id,
radioMessageIDHint: radioMessageIDHint,
senderNickname: baseNickname + "#" + String(event.pubkey.suffix(4)),
participantNickname: nickname?.trimmedOrNilIfEmpty,
senderPubkey: event.pubkey,
content: content,
timestamp: Date(timeIntervalSince1970: TimeInterval(event.created_at))
+10 -3
View File
@@ -542,6 +542,15 @@ final class KeychainManager: KeychainManagerProtocol {
/// Load data from a custom service
func load(key: String, service customService: String) -> Data? {
guard case .success(let data) = loadWithResult(key: key, service: customService) else {
return nil
}
return data
}
/// Load custom-service data without collapsing `itemNotFound` and
/// protected-data/keychain failures into the same nil result.
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: customService,
@@ -551,9 +560,7 @@ final class KeychainManager: KeychainManagerProtocol {
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else { return nil }
return result as? Data
return classifyReadStatus(status, data: result as? Data)
}
/// Delete data from a custom service
+14 -5
View File
@@ -367,16 +367,16 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
updatePermissionState(from: status)
if case .authorized = permissionState {
let newState = updatePermissionState(from: status)
if newState == .authorized {
requestOneShotLocation()
}
}
@available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState {
let newState = updatePermissionState(from: manager.authorizationStatus)
if newState == .authorized {
requestOneShotLocation()
}
}
@@ -393,7 +393,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Helpers (Permission)
private func updatePermissionState(from status: CLAuthorizationStatus) {
@discardableResult
private func updatePermissionState(from status: CLAuthorizationStatus) -> PermissionState {
let newState: PermissionState
switch status {
case .notDetermined: newState = .notDetermined
@@ -402,7 +403,15 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted
}
// Do not rely on a mounted SwiftUI consumer to stop high-accuracy
// updates. Authorization can change while the app is backgrounded,
// and stopping here also closes the gap before the published state is
// delivered on the main actor.
if newState != .authorized {
endLiveRefresh()
}
Task { @MainActor in self.permissionState = newState }
return newState
}
// MARK: - Private Helpers (Channel Computation)
@@ -246,6 +246,16 @@ final class MessageDeduplicationService {
ContentNormalizer.normalizedKey(content)
}
/// Removes the near-duplicate marker for a row that is being replaced,
/// not merely deleted. Bridge-first/radio-second reconciliation needs the
/// authenticated radio copy to pass the next pipeline flush after its
/// unauthenticated bridge alias is removed.
func forgetContent(_ content: String, ifRecordedAt timestamp: Date) {
let key = ContentNormalizer.normalizedKey(content)
guard contentCache.value(for: key) == timestamp else { return }
contentCache.remove(key)
}
// MARK: - Nostr Event Deduplication
/// Checks if a Nostr event has already been processed.
+69 -12
View File
@@ -56,11 +56,15 @@ final class MessageRouter {
/// relays as a courier drop, so delivery stops requiring a physical
/// courier encounter. No-op unless the bridge is enabled. Runs alongside
/// (not instead of) mesh couriers; receivers dedup by message ID.
/// Returns true when a fresh drop was sealed, so the sender's message
/// can show the "carried" state instead of sitting on "sending" forever
/// (the delivery ack has no radio route back until the peers next share
/// a transport).
var bridgeCourierDeposit: ((_ content: String, _ messageID: String, _ recipientNoiseKey: Data) -> Bool)?
/// Completion is true only after at least one default relay explicitly
/// accepts the event, so a socket write followed by rejection cannot
/// falsely show the sender's message as "carried".
var bridgeCourierDeposit: ((
_ content: String,
_ messageID: String,
_ recipientNoiseKey: Data,
_ completion: @escaping @MainActor (Bool) -> Void
) -> Void)?
/// Re-attempts bridge drops for retained messages whose recipient no
/// transport can promptly reach anymore. Covers sends that raced the BLE
@@ -77,9 +81,7 @@ final class MessageRouter {
}
guard !promptlyDeliverable else { continue }
for message in queue where now().timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds {
if bridgeCourierDeposit?(message.content, message.messageID, recipientKey) == true {
onMessageCarried?(message.messageID, peerID)
}
requestBridgeCourierDeposit(message, for: peerID, recipientKey: recipientKey)
}
}
}
@@ -102,6 +104,7 @@ final class MessageRouter {
}
private var bridgeSweepTask: Task<Void, Never>?
private var bridgeDepositsInFlight = Set<String>()
private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -127,6 +130,9 @@ final class MessageRouter {
self.outboxStore = outboxStore
self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:]
outboxStore?.setRecoveryHandler { [weak self] recovered in
self?.mergeRecoveredOutbox(recovered)
}
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -237,9 +243,7 @@ final class MessageRouter {
let entry = queuedMessage(messageID, for: peerID) else { return }
// The bridge drop needs no connected courier only the recipient
// key so it runs before the courier-slot bookkeeping.
if bridgeCourierDeposit?(entry.content, messageID, recipientKey) == true {
onMessageCarried?(messageID, peerID)
}
requestBridgeCourierDeposit(entry, for: peerID, recipientKey: recipientKey)
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
@@ -324,6 +328,23 @@ final class MessageRouter {
outbox[peerID]?.first { $0.messageID == messageID }
}
private func requestBridgeCourierDeposit(
_ message: QueuedMessage,
for peerID: PeerID,
recipientKey: Data
) {
guard let bridgeCourierDeposit,
bridgeDepositsInFlight.insert(message.messageID).inserted else { return }
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
guard let self else { return }
self.bridgeDepositsInFlight.remove(message.messageID)
// A direct delivery may have cleared the outbox while the relay
// relay confirmation was in flight; do not regress its UI state.
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
self.onMessageCarried?(message.messageID, peerID)
}
}
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited)
guard var queue = outbox[peerID],
@@ -344,10 +365,14 @@ final class MessageRouter {
outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true
}
// The durable snapshot may still be hidden by protected data. Record
// the ack even when this cold-load view cannot find the message, then
// persist the current view so the store retains a removal tombstone.
outboxStore?.recordRemoval(messageID: messageID)
if cleared {
metrics?.record(.outboxDelivered)
persistOutbox()
}
persistOutbox()
}
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
@@ -382,6 +407,38 @@ final class MessageRouter {
outboxStore?.save(outbox)
}
/// A cold BLE restoration can launch before protected files are readable.
/// The store initially returns an empty snapshot in that case, then calls
/// back after first unlock. Merge by message ID instead of replacing work
/// accepted during the locked wake, persist the union, and immediately
/// resume normal delivery attempts.
private func mergeRecoveredOutbox(_ recovered: MessageOutboxStore.Snapshot) {
for (peerID, recoveredQueue) in recovered {
var queue = outbox[peerID] ?? []
for var recoveredMessage in recoveredQueue {
if let index = queue.firstIndex(where: { $0.messageID == recoveredMessage.messageID }) {
recoveredMessage.sendAttempts = max(recoveredMessage.sendAttempts, queue[index].sendAttempts)
recoveredMessage.depositedCourierKeys.formUnion(queue[index].depositedCourierKeys)
queue[index] = recoveredMessage
} else {
queue.append(recoveredMessage)
}
}
queue.sort { $0.timestamp < $1.timestamp }
if queue.count > Self.maxMessagesPerPeer {
let overflow = queue.count - Self.maxMessagesPerPeer
for dropped in queue.prefix(overflow) {
dropMessage(dropped.messageID, for: peerID)
}
queue.removeFirst(overflow)
}
outbox[peerID] = queue
}
persistOutbox()
flushAllOutbox()
retryBridgeCourierDeposits()
}
/// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() {
outbox.removeAll()
+3
View File
@@ -215,6 +215,9 @@ enum TransportConfig {
// Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
// not pin the router's in-flight state indefinitely.
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance.
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0