diff --git a/bitchat/Noise/NoiseProtocol.swift b/bitchat/Noise/NoiseProtocol.swift index 665a5001..018efef5 100644 --- a/bitchat/Noise/NoiseProtocol.swift +++ b/bitchat/Noise/NoiseProtocol.swift @@ -93,6 +93,7 @@ enum NoisePattern { case XX // Most versatile, mutual authentication case IK // Initiator knows responder's static key case NK // Anonymous initiator + case X // One-way: single message to a known static key (no response) } enum NoiseRole { @@ -594,7 +595,7 @@ final class NoiseHandshakeState { switch pattern { case .XX: break // No pre-message keys - case .IK, .NK: + case .IK, .NK, .X: if role == .initiator, let remoteStatic = remoteStaticPublic { symmetricState.mixHash(remoteStatic.rawRepresentation) } else if role == .responder, let localStatic = localStaticPublic { @@ -897,6 +898,7 @@ extension NoisePattern { case .XX: return "XX" case .IK: return "IK" case .NK: return "NK" + case .X: return "X" } } @@ -918,6 +920,10 @@ extension NoisePattern { [.e, .es], // -> e, es [.e, .ee] // <- e, ee ] + case .X: + return [ + [.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message) + ] } } } diff --git a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift index f7117353..18b144d2 100644 --- a/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift +++ b/bitchat/Services/BLE/BLEOutboundPacketPolicy.swift @@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy { switch MessageType(rawValue: packetType) { case .noiseEncrypted, .noiseHandshake: return true - case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: + case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope: return false } } diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 9959f179..dfea2491 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -46,6 +46,20 @@ final class BLEService: NSObject { // 4. Efficient Message Deduplication private let messageDeduplicator = MessageDeduplicator() + + // Courier store-and-forward: envelopes this device carries for offline + // third parties, and the trust gate for accepting deposits. Injectable + // for tests; main-actor policy because favorites live on the main actor. + var courierStore: CourierStore = .shared + var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in + FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) + } + + #if DEBUG + // Test-only tap on the outbound pipeline so multi-node tests can ferry + // packets between in-process service instances. + var _test_onOutboundPacket: ((BitchatPacket) -> Void)? + #endif private var selfBroadcastTracker = BLESelfBroadcastTracker() private let meshTopology = MeshTopologyTracker() @@ -860,6 +874,10 @@ final class BLEService: NSObject { } else { packetToSend = packet } + + #if DEBUG + _test_onOutboundPacket?(packetToSend) + #endif // Encode once using a small per-type padding policy, then delegate by type let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) @@ -2439,7 +2457,129 @@ extension BLEService { ttl: messageTTL ) } - + + // MARK: Courier Store-and-Forward + + /// Seal `content` to the recipient's static key (one-way Noise X) and hand + /// the envelope to the given couriers for physical delivery. Returns false + /// when no courier is connected; sealing and sending happen asynchronously. + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + let connected = couriers.filter { isPeerConnected($0) } + guard !connected.isEmpty, + let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else { + return false + } + messageQueue.async { [weak self] in + guard let self else { return } + do { + let now = Date() + let sealed = try self.noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey) + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientNoiseKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), + ciphertext: sealed + ) + guard let payload = envelope.encode() else { return } + for courier in connected { + SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + self.broadcastPacket(self.makeCourierPacket(payload, to: courier)) + } + } catch { + SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption) + } + } + return true + } + + private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { + BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: myPeerIDData, + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: messageTTL + ) + } + + /// Handles both courier roles for an incoming envelope addressed to us: + /// recipient (the rotating tag matches our static key → open and deliver) + /// or courier (a trusted peer is depositing mail for someone else → store). + private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) { + // Directed packets only; envelopes addressed elsewhere ride the + // generic relay path untouched. + guard packet.recipientID == myPeerIDData else { return } + guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return } + + let myKey = noiseService.getStaticPublicKeyData() + if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) { + openCourierEnvelope(envelope) + } else { + acceptCourierDeposit(envelope, from: peerID) + } + } + + private func openCourierEnvelope(_ envelope: CourierEnvelope) { + do { + let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext) + guard let typeRaw = typedPayload.first, + let payloadType = NoisePayloadType(rawValue: typeRaw), + payloadType == .privateMessage else { + SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session) + return + } + // Mesh peer IDs are derived from Noise static keys, so the sender + // resolves to the same DM thread whether or not they're present. + let senderPeerID = PeerID(publicKey: senderStaticKey) + let payload = Data(typedPayload.dropFirst()) + SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session) + notifyUI { [weak self] in + self?.deliverTransportEvent(.noisePayloadReceived( + peerID: senderPeerID, + type: payloadType, + payload: payload, + timestamp: Date() + )) + } + } catch { + // Tag collision or stale key: not addressed to us after all. + SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption) + } + } + + private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) { + guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else { + SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session) + return + } + let store = courierStore + let policy = courierDepositPolicy + Task { @MainActor in + guard policy(depositorKey) else { + SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session) + return + } + if store.deposit(envelope, from: depositorKey) { + SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session) + } + } + } + + /// 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 } + broadcastPacket(makeCourierPacket(payload, to: peerID)) + } + } + // MARK: Link capability snapshots (thread-safe via bleQueue) private func readLinkState(_ body: (BLELinkStateStore) -> T) -> T { @@ -2924,7 +3064,10 @@ extension BLEService { case .fileTransfer: handleFileTransfer(packet, from: senderID) - + + case .courierEnvelope: + handleCourierEnvelope(packet, from: senderID) + case .leave: handleLeave(packet, from: senderID) @@ -2988,6 +3131,20 @@ extension BLEService { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { announceHandler.handle(packet, from: peerID) + + // Courier handover: an announce is the moment we learn a peer's Noise + // static key, so check whether we're carrying mail addressed to them. + // Re-run the preflight so handover only follows announces that pass + // the key<->peerID binding check (mirrors the handler's own gate). + guard !courierStore.isEmpty else { return } + if case .accept(let acceptance) = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: myPeerID, + now: Date() + ) { + deliverCourierMail(to: peerID, noiseKey: acceptance.announcement.noisePublicKey) + } } /// Builds the announce handler environment. All queue hops stay here so diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift new file mode 100644 index 00000000..24bbc1b4 --- /dev/null +++ b/bitchat/Services/Courier/CourierStore.swift @@ -0,0 +1,217 @@ +// +// CourierStore.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import BitLogger +import Combine +import Foundation + +/// Holds courier envelopes this device is carrying for offline third parties. +/// +/// Envelopes are opaque ciphertext deposited by mutual favorites; this store +/// never learns sender, recipient, or content. Strict quotas keep the device +/// from becoming a public mailbag: bounded count, bounded per-depositor +/// count, bounded size, and a 24-hour lifetime aligned with the outbox +/// retention policy. Carried mail is included in the panic wipe. +final class CourierStore { + struct StoredEnvelope: Codable, Equatable { + let recipientTag: Data + let expiry: UInt64 + let ciphertext: Data + let depositorNoiseKey: Data + let storedAt: Date + + var envelope: CourierEnvelope { + CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext) + } + } + + enum Limits { + static let maxEnvelopes = 20 + static let maxPerDepositor = 5 + /// Slack on top of the 24h lifetime for depositor clock skew. + static let maxExpirySlack: TimeInterval = 60 * 60 + } + + static let shared = CourierStore() + + /// Number of envelopes currently carried, published on the main thread + /// so the UI can show a "carrying mail" indicator. + @Published private(set) var carriedCount: Int = 0 + + /// Fast path so hot code (announce handling) can skip tag computation. + var isEmpty: Bool { + queue.sync { envelopes.isEmpty } + } + + private var envelopes: [StoredEnvelope] = [] + private let queue = DispatchQueue(label: "chat.bitchat.courier.store") + private let fileURL: URL? + private let now: () -> Date + + init(persistsToDisk: Bool = true, now: @escaping () -> Date = Date.init) { + self.now = now + self.fileURL = persistsToDisk ? Self.defaultFileURL() : nil + loadFromDisk() + } + + // MARK: - Depositing (courier side) + + /// Accept an envelope from a depositor. Returns false when quotas or + /// validity checks reject it. Trust policy (mutual favorite) is the + /// caller's responsibility; this store only enforces resource bounds. + @discardableResult + func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool { + let date = now() + guard envelope.recipientTag.count == CourierEnvelope.tagLength, + !envelope.ciphertext.isEmpty, + envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes, + !envelope.isExpired(at: date) else { + return false + } + // Reject expiries beyond the policy lifetime so depositors can't pin + // storage longer than the outbox would retain the message itself. + let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack) + guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else { + return false + } + + return queue.sync { + pruneExpiredLocked(at: date) + + // Identical ciphertext is the same envelope; accept idempotently. + if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) { + return true + } + guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else { + SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session) + return false + } + if envelopes.count >= Limits.maxEnvelopes { + // Oldest-first eviction, matching outbox overflow behavior. + let evicted = envelopes.removeFirst() + SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session) + } + + envelopes.append(StoredEnvelope( + recipientTag: envelope.recipientTag, + expiry: envelope.expiry, + ciphertext: envelope.ciphertext, + depositorNoiseKey: depositorNoiseKey, + storedAt: date + )) + persistLocked() + return true + } + } + + // 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. + func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] { + let date = now() + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date) + return 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) + } + } + + // MARK: - Maintenance + + func pruneExpired() { + let date = now() + queue.sync { + pruneExpiredLocked(at: date) + persistLocked() + } + } + + /// Panic wipe: drop all carried mail from memory and disk. + func wipe() { + queue.sync { + envelopes.removeAll() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + publishCountLocked() + } + } + + // MARK: - Internals (call only on `queue`) + + private func pruneExpiredLocked(at date: Date) { + let before = envelopes.count + envelopes.removeAll { $0.envelope.isExpired(at: date) } + if envelopes.count != before { + SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session) + } + } + + private func publishCountLocked() { + let count = envelopes.count + DispatchQueue.main.async { [weak self] in + self?.carriedCount = count + } + } + + private func persistLocked() { + publishCountLocked() + guard let fileURL else { return } + do { + if envelopes.isEmpty { + try? FileManager.default.removeItem(at: fileURL) + return + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let data = try JSONEncoder().encode(envelopes) + var options: Data.WritingOptions = [.atomic] + #if os(iOS) + options.insert(.completeFileProtection) + #endif + try data.write(to: fileURL, options: options) + } catch { + SecureLogger.error("Failed to persist courier store: \(error)", category: .session) + } + } + + 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 { + return + } + envelopes = stored + pruneExpiredLocked(at: now()) + publishCountLocked() + } + } + + private static func defaultFileURL() -> URL? { + guard let base = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return base + .appendingPathComponent("courier", isDirectory: true) + .appendingPathComponent("envelopes.json") + } +} diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index af0b3291..e99c1a3f 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -2,11 +2,33 @@ import BitLogger import BitFoundation import Foundation +/// Trust and identity lookups the router needs to pick couriers. Backed by +/// the favorites store in production; injectable for tests. +struct CourierDirectory { + /// Noise static key for a peer we can address while they're offline. + var noiseKey: (PeerID) -> Data? + /// Whether a peer (by Noise static key) may carry our mail. + var isTrustedCourier: (Data) -> Bool + + @MainActor + static func favoritesBacked() -> CourierDirectory { + CourierDirectory( + noiseKey: { peerID in + FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey + }, + isTrustedCourier: { noiseKey in + FavoritesPersistenceService.shared.isMutualFavorite(noiseKey) + } + ) + } +} + /// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { private let transports: [Transport] private let now: () -> Date + private let courierDirectory: CourierDirectory /// Invoked whenever a retained private message is dropped without a /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) @@ -14,6 +36,12 @@ final class MessageRouter { /// stale "sending/sent" state forever. var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? + /// Invoked when a message with no reachable transport was handed to at + /// least one courier (a connected mutual favorite who will physically + /// carry the sealed envelope). Delivery stays best-effort: the outbox + /// retains the message until an ack arrives. + var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? + // Outbox entry with timestamp for TTL-based eviction private struct QueuedMessage { let content: String @@ -31,10 +59,17 @@ final class MessageRouter { // Bound resends of messages sent on a weak reachability signal that never // get a delivery ack (e.g. peer on an old client that doesn't ack). private static let maxSendAttempts = 8 + // Redundant couriers improve delivery odds; receivers dedup by message ID. + private static let maxCouriersPerMessage = 3 - init(transports: [Transport], now: @escaping () -> Date = Date.init) { + init( + transports: [Transport], + now: @escaping () -> Date = Date.init, + courierDirectory: CourierDirectory? = nil + ) { self.transports = transports self.now = now + self.courierDirectory = courierDirectory ?? .favoritesBacked() // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -94,6 +129,33 @@ final class MessageRouter { unsent.sendAttempts = 0 enqueue(unsent, for: peerID) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) + attemptCourierDeposit(content: content, messageID: messageID, for: peerID) + } + } + + /// Last resort when no transport can reach the peer: seal the message to + /// their known static key and hand it to connected mutual favorites who + /// may physically encounter them. The queued copy above stays retained, + /// so direct delivery still wins if the peer reappears first (receivers + /// dedup by message ID). + private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) { + guard let recipientKey = courierDirectory.noiseKey(peerID) else { return } + for transport in transports { + let couriers = transport.currentPeerSnapshots() + .filter { snapshot in + guard snapshot.isConnected, + let key = snapshot.noisePublicKey, + key != recipientKey else { return false } + return courierDirectory.isTrustedCourier(key) + } + .prefix(Self.maxCouriersPerMessage) + .map(\.peerID) + guard !couriers.isEmpty else { continue } + if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) { + SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session) + onMessageCarried?(messageID, peerID) + return + } } } diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 70e43c0d..d2a3fea0 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -369,6 +369,49 @@ final class NoiseEncryptionService { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation } + + // MARK: - Courier Envelopes (one-way Noise X) + + /// Domain separation for courier envelopes so X-pattern transcripts can + /// never be confused with interactive XX handshakes. + private static let courierPrologue = Data("bitchat-courier-v1".utf8) + + /// Encrypt a payload to a peer's known static key without an interactive + /// handshake (Noise X pattern). Used for store-and-forward envelopes + /// carried by couriers while the recipient is offline. + /// - Warning: One-way messages have no forward secrecy: a later compromise + /// of the recipient's static key exposes envelopes captured in transit. + /// Use established sessions whenever the peer is reachable. + func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data { + let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey) + let handshake = NoiseHandshakeState( + role: .initiator, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + remoteStaticKey: remoteKey, + prologue: Self.courierPrologue + ) + return try handshake.writeMessage(payload: payload) + } + + /// Decrypt a courier envelope addressed to our static key. Returns the + /// payload and the sender's authenticated static public key (the `ss` + /// DH in the X pattern binds the sender's identity to the ciphertext). + func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) { + let handshake = NoiseHandshakeState( + role: .responder, + pattern: .X, + keychain: keychain, + localStaticKey: staticIdentityKey, + prologue: Self.courierPrologue + ) + let payload = try handshake.readMessage(envelopeCiphertext) + guard let senderKey = handshake.getRemoteStaticPublicKey() else { + throw NoiseError.missingKeys + } + return (payload: payload, senderStaticKey: senderKey.rawRepresentation) + } /// Clear persistent identity (for panic mode) func clearPersistentIdentity() { diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 4633698e..c979b073 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject { case .read, .delivered: externalReceipts.insert(message.id) sentReadReceipts.insert(message.id) - case .failed, .partiallyDelivered, .sending, .sent: + case .failed, .partiallyDelivered, .sending, .sent, .carried: break } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 36ca832b..afd1680e 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -95,6 +95,12 @@ protocol Transport: AnyObject { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func cancelTransfer(_ transferId: String) + // Courier store-and-forward (mesh transports only): seal a message to the + // recipient's static key and hand it to connected couriers for physical + // delivery while the recipient is offline. Returns false when the + // transport cannot courier (no connected courier, or unsupported). + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool + // QR verification (optional for transports) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -120,6 +126,7 @@ extension Transport { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func cancelTransfer(_ transferId: String) {} diff --git a/bitchat/Sync/SyncTypeFlags.swift b/bitchat/Sync/SyncTypeFlags.swift index 19261314..430485a0 100644 --- a/bitchat/Sync/SyncTypeFlags.swift +++ b/bitchat/Sync/SyncTypeFlags.swift @@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet { case .fragment: return 5 case .requestSync: return 6 case .fileTransfer: return 7 + // Courier envelopes are directed deposits between trusted peers and + // must never spread via gossip sync. + case .courierEnvelope: return nil } } diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index e93b7eb9..bea1b5b2 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator { case .failed: return 1 case .sending: return 2 case .sent: return 3 - case .partiallyDelivered: return 4 - case .delivered: return 5 - case .read: return 6 + case .carried: return 4 + case .partiallyDelivered: return 5 + case .delivered: return 6 + case .read: return 7 } } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 00f10ae3..a7d8300d 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1142,6 +1142,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // Clear persistent favorites from keychain FavoritesPersistenceService.shared.clearAllFavorites() + // Drop courier mail carried for third parties (memory and disk) + CourierStore.shared.wipe() + // Identity manager has cleared persisted identity data above // Clear autocomplete state diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index e2615cf9..66d9c8f5 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -105,6 +105,23 @@ private extension ChatViewModelBootstrapper { ) } } + // A message with no reachable transport that was handed to a courier + // shows a distinct "carried" state instead of sitting in "sending" + // forever. Never downgrade a confirmed receipt: the courier copy can + // race direct delivery when the peer reappears. + viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in + guard let viewModel else { return } + switch viewModel.conversations.deliveryStatus(forMessageID: messageID) { + case .delivered, .read: + break + default: + SecureLogger.debug( + "📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried", + category: .session + ) + viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID) + } + } viewModel.commandProcessor.contextProvider = viewModel viewModel.commandProcessor.meshService = viewModel.meshService viewModel.participantTracker.configure(context: viewModel) diff --git a/bitchat/Views/Components/DeliveryStatusView.swift b/bitchat/Views/Components/DeliveryStatusView.swift index 563bd9aa..49334777 100644 --- a/bitchat/Views/Components/DeliveryStatusView.swift +++ b/bitchat/Views/Components/DeliveryStatusView.swift @@ -44,6 +44,10 @@ struct DeliveryStatusView: View { ) } + static var carried: String { + String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Tooltip for messages handed to a courier for physical delivery") + } + static func deliveredToMembers(_ reached: Int, _ total: Int) -> String { String( format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), @@ -68,6 +72,12 @@ struct DeliveryStatusView: View { .font(.bitchatSystem(size: 10)) .foregroundColor(secondaryTextColor.opacity(0.6)) + case .carried: + Image(systemName: "figure.walk") + .font(.bitchatSystem(size: 10)) + .foregroundColor(secondaryTextColor.opacity(0.8)) + .help(Strings.carried) + case .delivered(let nickname, _): HStack(spacing: -2) { Image(systemName: "checkmark") @@ -111,6 +121,7 @@ struct DeliveryStatusView: View { let statuses: [DeliveryStatus] = [ .sending, .sent, + .carried, .delivered(to: "John Doe", at: Date()), .read(by: "Jane Doe", at: Date()), .failed(reason: "Offline"), diff --git a/bitchat/Views/ContentHeaderView.swift b/bitchat/Views/ContentHeaderView.swift index 3c20baa7..a9d7185b 100644 --- a/bitchat/Views/ContentHeaderView.swift +++ b/bitchat/Views/ContentHeaderView.swift @@ -22,6 +22,9 @@ struct ContentHeaderView: View { let headerPeerIconSize: CGFloat let headerPeerCountFontSize: CGFloat + /// Courier envelopes this device is carrying for offline third parties. + @State private var carriedMailCount = 0 + var body: some View { HStack(spacing: 0) { Text(verbatim: "bitchat/") @@ -78,6 +81,23 @@ struct ContentHeaderView: View { }() HStack(spacing: 2) { + if carriedMailCount > 0 { + Image(systemName: "figure.walk") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary.opacity(0.8)) + .headerTapTarget() + .accessibilityLabel( + String( + format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"), + locale: .current, + carriedMailCount + ) + ) + .help( + String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator") + ) + } + if appChromeModel.hasUnreadPrivateMessages { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Image(systemName: "envelope.fill") @@ -192,6 +212,9 @@ struct ContentHeaderView: View { } .frame(height: headerHeight) .padding(.horizontal, 12) + .onReceive(CourierStore.shared.$carriedCount) { count in + carriedMailCount = count + } .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) .environmentObject(locationChannelsModel) diff --git a/bitchat/Views/Media/MediaMessageView.swift b/bitchat/Views/Media/MediaMessageView.swift index 8bee3bd2..4a84ca5a 100644 --- a/bitchat/Views/Media/MediaMessageView.swift +++ b/bitchat/Views/Media/MediaMessageView.swift @@ -90,7 +90,7 @@ struct MediaMessageView: View { isSending = true progress = Double(reached) / Double(total) } - case .sent, .read, .delivered, .failed: + case .sent, .carried, .read, .delivered, .failed: break } } diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 78365b66..7d68a3dc 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests { @Test @MainActor func statusRank_orderingIsCorrect() async { // This tests the implicit ordering used in refreshVisibleMessages - // failed < sending < sent < partiallyDelivered < delivered < read + // failed < sending < sent < carried < partiallyDelivered < delivered < read let statuses: [DeliveryStatus] = [ .failed(reason: "test"), .sending, .sent, + .carried, .partiallyDelivered(reached: 1, total: 3), .delivered(to: "B", at: Date()), .read(by: "C", at: Date()) @@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests { case .failed: #expect(index == 0) case .sending: #expect(index == 1) case .sent: #expect(index == 2) - case .partiallyDelivered: #expect(index == 3) - case .delivered: #expect(index == 4) - case .read: #expect(index == 5) + case .carried: #expect(index == 3) + case .partiallyDelivered: #expect(index == 4) + case .delivered: #expect(index == 5) + case .read: #expect(index == 6) } } } diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift new file mode 100644 index 00000000..162bea58 --- /dev/null +++ b/bitchatTests/CourierStoreTests.swift @@ -0,0 +1,173 @@ +// +// CourierStoreTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +struct CourierStoreTests { + + private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000) + + private func makeStore(now: Date = baseDate) -> CourierStore { + CourierStore(persistsToDisk: false, now: { now }) + } + + /// Store whose clock can be advanced by tests. + private final class Clock { + var now: Date + init(_ now: Date) { self.now = now } + } + + private func makeEnvelope( + recipientKey: Data = Data(repeating: 0xB0, count: 32), + sealedAt: Date = baseDate, + lifetime: TimeInterval = 60 * 60, + ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) }) + ) -> CourierEnvelope { + CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: recipientKey, + epochDay: CourierEnvelope.epochDay(for: sealedAt) + ), + expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000), + ciphertext: ciphertext + ) + } + + private let depositorA = Data(repeating: 0xA1, count: 32) + private let depositorB = Data(repeating: 0xA2, count: 32) + + // MARK: - Deposit and handover + + @Test func depositThenTakeForRecipient() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + + #expect(store.deposit(envelope, from: depositorA)) + let taken = store.takeEnvelopes(for: recipientKey) + #expect(taken == [envelope]) + // Handover removes the envelope. + #expect(store.takeEnvelopes(for: recipientKey).isEmpty) + } + + @Test func takeIgnoresOtherRecipients() { + let store = makeStore() + let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32)) + store.deposit(envelope, from: depositorA) + #expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty) + #expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1) + } + + @Test func duplicateDepositIsIdempotent() { + let store = makeStore() + let recipientKey = Data(repeating: 0xB0, count: 32) + let envelope = makeEnvelope(recipientKey: recipientKey) + #expect(store.deposit(envelope, from: depositorA)) + #expect(store.deposit(envelope, from: depositorA)) + #expect(store.takeEnvelopes(for: recipientKey).count == 1) + } + + // MARK: - Validity + + @Test func rejectsExpiredAndOversizedAndMalformed() { + let store = makeStore() + let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600) + #expect(!store.deposit(expired, from: depositorA)) + + let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)) + #expect(!store.deposit(oversized, from: depositorA)) + + let badTag = CourierEnvelope( + recipientTag: Data(repeating: 0, count: 4), + expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000), + ciphertext: Data(repeating: 1, count: 16) + ) + #expect(!store.deposit(badTag, from: depositorA)) + } + + @Test func rejectsExpiryBeyondPolicyLifetime() { + let store = makeStore() + let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60) + #expect(!store.deposit(pinned, from: depositorA)) + } + + // MARK: - Quotas + + @Test func perDepositorQuota() { + let store = makeStore() + for _ in 0.. +// + +import Testing +import Foundation +import Combine +import CoreBluetooth +import BitFoundation +@testable import bitchat + +/// Three-node courier flow exercised through real BLEService instances with +/// packets ferried in-process: Alice deposits a sealed envelope with Carol +/// while Bob is unreachable; Carol hands it over when Bob announces; Bob +/// opens it and sees Alice's message in the right DM thread. +struct CourierEndToEndTests { + + // MARK: - Helpers + + private final class PacketTap { + private let lock = NSLock() + private var packets: [BitchatPacket] = [] + + func record(_ packet: BitchatPacket) { + lock.lock(); packets.append(packet); lock.unlock() + } + + func first(ofType type: MessageType) -> BitchatPacket? { + lock.lock(); defer { lock.unlock() } + return packets.first { $0.type == type.rawValue } + } + } + + private final class NoiseCaptureDelegate: BitchatDelegate { + private let lock = NSLock() + private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = [] + + func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { + lock.lock(); payloads.append((peerID, type, payload)); lock.unlock() + } + + func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] { + lock.lock(); defer { lock.unlock() } + return payloads + } + + // Unused BitchatDelegate requirements. + func didReceiveMessage(_ message: BitchatMessage) {} + func didConnectToPeer(_ peerID: PeerID) {} + func didDisconnectFromPeer(_ peerID: PeerID) {} + func didUpdatePeerList(_ peers: [PeerID]) {} + func didUpdateBluetoothState(_ state: CBManagerState) {} + func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {} + } + + private func makeService() -> BLEService { + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) + let service = BLEService( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + initializeBluetoothManagers: false + ) + service.courierStore = CourierStore(persistsToDisk: false) + return service + } + + /// Handling any packet from a peer preseeds it as a connected, + /// verified entry in the receiving service's registry. + private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) { + let packet = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: peer.myPeerID.id) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data("ping".utf8), + signature: nil, + ttl: 1 + ) + service._test_handlePacket(packet, fromPeerID: peer.myPeerID) + } + + // MARK: - Tests + + @Test func courierCarriesMessageAcrossDisjointConnectivity() async throws { + let alice = makeService() + let carol = makeService() + let bob = makeService() + // Alice and Carol are mutual favorites; trust policy is exercised + // separately in depositFromUntrustedPeerIsRejected. + carol.courierDepositPolicy = { _ in true } + + let bobDelegate = NoiseCaptureDelegate() + bob.delegate = bobDelegate + + let aliceOut = PacketTap() + alice._test_onOutboundPacket = aliceOut.record + let carolOut = PacketTap() + carol._test_onOutboundPacket = carolOut.record + let bobOut = PacketTap() + bob._test_onOutboundPacket = bobOut.record + + // Alice can see Carol; Bob is nowhere on the mesh. + preseedConnectedPeer(carol, in: alice) + + // 1. Alice seals to Bob's static key and deposits with Carol. + #expect(alice.sendCourierMessage( + "the camp moved north", + messageID: "courier-msg-1", + recipientNoiseKey: bob.noiseStaticPublicKeyData(), + via: [carol.myPeerID] + )) + let deposited = await TestHelpers.waitUntil( + { aliceOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(deposited) + let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) + + // 2. Ferry the deposit to Carol; she carries it (opaque to her). + carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID) + let carried = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(carried) + + // 3. Later, Bob announces near Carol → handover fires. + bob.sendBroadcastAnnounce() + let announced = await TestHelpers.waitUntil( + { bobOut.first(ofType: .announce) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(announced) + let announcePacket = try #require(bobOut.first(ofType: .announce)) + carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID) + + let handedOver = await TestHelpers.waitUntil( + { carolOut.first(ofType: .courierEnvelope) != nil }, + timeout: TestConstants.defaultTimeout + ) + #expect(handedOver) + #expect(carol.courierStore.isEmpty) + let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope)) + #expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID) + + // 4. Ferry the handover to Bob; he opens the envelope. + bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID) + let received = await TestHelpers.waitUntil( + { !bobDelegate.snapshot().isEmpty }, + timeout: TestConstants.defaultTimeout + ) + #expect(received) + + let delivered = try #require(bobDelegate.snapshot().first) + #expect(delivered.type == .privateMessage) + // Sender resolves to Alice's stable mesh identity, not the courier's. + #expect(delivered.peerID == alice.myPeerID) + let message = try #require(PrivateMessagePacket.decode(from: delivered.payload)) + #expect(message.messageID == "courier-msg-1") + #expect(message.content == "the camp moved north") + } + + @Test func depositFromUntrustedPeerIsRejected() async throws { + let carol = makeService() + carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite + + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() + let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1")) + let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey) + let now = Date() + let envelope = CourierEnvelope( + recipientTag: CourierEnvelope.recipientTag( + noiseStaticKey: bobKey, + epochDay: CourierEnvelope.epochDay(for: now) + ), + expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000), + ciphertext: sealed + ) + let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) + let packet = BitchatPacket( + type: MessageType.courierEnvelope.rawValue, + senderID: Data(hexString: alicePeerID.id) ?? Data(), + recipientID: Data(hexString: carol.myPeerID.id), + timestamp: UInt64(now.timeIntervalSince1970 * 1000), + payload: try #require(envelope.encode()), + signature: nil, + ttl: 1 + ) + + carol._test_handlePacket(packet, fromPeerID: alicePeerID) + let stored = await TestHelpers.waitUntil( + { !carol.courierStore.isEmpty }, + timeout: TestConstants.shortTimeout + ) + #expect(!stored) + } +} + +// MARK: - Router courier selection + +/// Minimal transport stub for exercising MessageRouter's courier deposit +/// logic without BLE plumbing. +private final class CourierCaptureTransport: Transport { + weak var delegate: BitchatDelegate? + weak var eventDelegate: TransportEventDelegate? + weak var peerEventsDelegate: TransportPeerEventsDelegate? + + var snapshots: [TransportPeerSnapshot] = [] + private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = [] + private(set) var directSends: [String] = [] + + var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { + Just(snapshots).eraseToAnyPublisher() + } + func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots } + + var myPeerID = PeerID(str: "00000000000000aa") + var myNickname = "stub" + func setNickname(_ nickname: String) {} + + func startServices() {} + func stopServices() {} + func emergencyDisconnectAll() {} + + func isPeerConnected(_ peerID: PeerID) -> Bool { + snapshots.contains { $0.peerID == peerID && $0.isConnected } + } + func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) } + func peerNickname(peerID: PeerID) -> String? { nil } + func getPeerNicknames() -> [PeerID: String] { [:] } + + func getFingerprint(for peerID: PeerID) -> String? { nil } + func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } + func triggerHandshake(with peerID: PeerID) {} + + func sendMessage(_ content: String, mentions: [String]) {} + func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + directSends.append(messageID) + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {} + func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} + func sendBroadcastAnnounce() {} + func sendDeliveryAck(for messageID: String, to peerID: PeerID) {} + + func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { + courierSends.append((messageID, recipientNoiseKey, couriers)) + return true + } +} + +struct MessageRouterCourierTests { + + @Test @MainActor + func unreachablePeerMessageGoesToTrustedCouriersOnly() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let carolKey = Data(repeating: 0xC0, count: 32) + let carolID = PeerID(publicKey: carolKey) + let daveKey = Data(repeating: 0xD0, count: 32) + let daveID = PeerID(publicKey: daveKey) + + let transport = CourierCaptureTransport() + transport.snapshots = [ + // Carol: connected mutual favorite → eligible courier. + TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()), + // Dave: connected but not trusted → never a courier. + TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date()) + ] + + let directory = CourierDirectory( + noiseKey: { peerID in peerID == bobID ? bobKey : nil }, + isTrustedCourier: { $0 == carolKey } + ) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1") + + #expect(transport.directSends.isEmpty) + #expect(transport.courierSends.count == 1) + #expect(transport.courierSends.first?.messageID == "m1") + #expect(transport.courierSends.first?.recipientKey == bobKey) + #expect(transport.courierSends.first?.couriers == [carolID]) + #expect(carried == ["m1"]) + } + + @Test @MainActor + func noCourierDepositWithoutKnownRecipientKey() { + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date()) + ] + let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true }) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + var carried: [String] = [] + router.onMessageCarried = { messageID, _ in carried.append(messageID) } + + router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2") + + #expect(transport.courierSends.isEmpty) + #expect(carried.isEmpty) + } + + @Test @MainActor + func reachablePeerSkipsCourier() { + let bobKey = Data(repeating: 0xB0, count: 32) + let bobID = PeerID(publicKey: bobKey) + let transport = CourierCaptureTransport() + transport.snapshots = [ + TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date()) + ] + let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true }) + let router = MessageRouter(transports: [transport], courierDirectory: directory) + + router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3") + + #expect(transport.directSends == ["m3"]) + #expect(transport.courierSends.isEmpty) + } +} diff --git a/bitchatTests/NoiseCourierTests.swift b/bitchatTests/NoiseCourierTests.swift new file mode 100644 index 00000000..1403e520 --- /dev/null +++ b/bitchatTests/NoiseCourierTests.swift @@ -0,0 +1,107 @@ +// +// NoiseCourierTests.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import bitchat + +/// One-way Noise X envelopes: encryption to a known static key without an +/// interactive handshake, used by the courier store-and-forward path. +struct NoiseCourierTests { + + @Test func sealAndOpenRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + let payload = Data("meet at the north gate".utf8) + let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + + let opened = try bob.openCourierPayload(sealed) + #expect(opened.payload == payload) + // The X pattern authenticates the sender: Bob learns Alice's real static key. + #expect(opened.senderStaticKey == alice.getStaticPublicKeyData()) + } + + @Test func wrongRecipientCannotOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let carol = NoiseEncryptionService(keychain: MockKeychain()) + + let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + + #expect(throws: (any Error).self) { + _ = try carol.openCourierPayload(sealed) + } + } + + @Test func tamperedEnvelopeFailsToOpen() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData()) + sealed[sealed.count - 1] ^= 0x01 + + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(sealed) + } + } + + @Test func senderIdentityCannotBeForged() throws { + // The encrypted static key inside the envelope is bound by the ss DH; + // splicing one envelope's ephemeral prefix onto another must fail. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let mallory = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + + let bobKey = bob.getStaticPublicKeyData() + let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) + let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey) + + // e (32 bytes) from Mallory's envelope + rest from Alice's. + let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32) + #expect(throws: (any Error).self) { + _ = try bob.openCourierPayload(Data(spliced)) + } + } + + @Test func sealRejectsInvalidRecipientKey() { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + #expect(throws: (any Error).self) { + _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32)) + } + #expect(throws: (any Error).self) { + _ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8)) + } + } + + @Test func emptyAndLargePayloadsRoundTrip() throws { + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let bobKey = bob.getStaticPublicKeyData() + + let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey) + #expect(try bob.openCourierPayload(empty).payload.isEmpty) + + let large = Data((0..<8192).map { UInt8($0 % 251) }) + let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey) + #expect(try bob.openCourierPayload(sealed).payload == large) + } + + @Test func envelopesAreNotLinkableAcrossSends() throws { + // Fresh ephemeral per seal: same payload to the same recipient must + // produce entirely different ciphertexts. + let alice = NoiseEncryptionService(keychain: MockKeychain()) + let bob = NoiseEncryptionService(keychain: MockKeychain()) + let payload = Data("same message".utf8) + + let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData()) + #expect(a != b) + #expect(a.prefix(32) != b.prefix(32)) + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift new file mode 100644 index 00000000..4bb99de3 --- /dev/null +++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift @@ -0,0 +1,147 @@ +// +// CourierEnvelope.swift +// BitFoundation +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +private import CryptoKit + +/// TLV payload for store-and-forward courier envelopes. +/// +/// A courier envelope lets a mutual favorite physically carry an encrypted +/// message to a peer who is currently offline. The envelope is opaque to the +/// courier: the only routing information is a rotating recipient tag derived +/// from the recipient's Noise static public key and the UTC day, so envelopes +/// addressed to the same peer on different days do not correlate for +/// observers who don't already know that peer's public key. +public struct CourierEnvelope: Equatable { + /// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated. + public let recipientTag: Data + /// Milliseconds since epoch after which the envelope must be discarded. + public let expiry: UInt64 + /// Opaque one-way Noise X ciphertext (sender identity rides inside). + public let ciphertext: Data + + public static let tagLength = 16 + /// Couriered messages are text-sized; media transfers are out of scope. + public static let maxCiphertextBytes = 16 * 1024 + /// Matches the outbox retention policy in MessageRouter. + public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60 + + private enum TLVType: UInt8 { + case recipientTag = 0x01 + case expiry = 0x02 + case ciphertext = 0x03 + } + + public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) { + self.recipientTag = recipientTag + self.expiry = expiry + self.ciphertext = ciphertext + } + + public var isExpired: Bool { + isExpired(at: Date()) + } + + public func isExpired(at date: Date) -> Bool { + UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry + } + + public func encode() -> Data? { + guard recipientTag.count == Self.tagLength else { return nil } + guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil } + + func appendBE(_ value: T, into data: inout Data) { + var big = value.bigEndian + withUnsafeBytes(of: &big) { data.append(contentsOf: $0) } + } + + var encoded = Data() + encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count) + + encoded.append(TLVType.recipientTag.rawValue) + appendBE(UInt16(recipientTag.count), into: &encoded) + encoded.append(recipientTag) + + encoded.append(TLVType.expiry.rawValue) + appendBE(UInt16(8), into: &encoded) + appendBE(expiry, into: &encoded) + + encoded.append(TLVType.ciphertext.rawValue) + appendBE(UInt16(ciphertext.count), into: &encoded) + encoded.append(ciphertext) + + return encoded + } + + public static func decode(_ data: Data) -> CourierEnvelope? { + var cursor = data.startIndex + let end = data.endIndex + + var recipientTag: Data? + var expiry: UInt64? + var ciphertext: Data? + + while cursor < end { + let typeRaw = data[cursor] + cursor = data.index(after: cursor) + + guard data.distance(from: cursor, to: end) >= 2 else { return nil } + let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)]) + cursor = data.index(cursor, offsetBy: 2) + guard data.distance(from: cursor, to: end) >= length else { return nil } + let value = data[cursor.. 0, length <= maxCiphertextBytes else { return nil } + ciphertext = Data(value) + case nil: + // Unknown TLV: skip for forward compatibility. + continue + } + } + + guard let recipientTag, let expiry, let ciphertext else { return nil } + return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext) + } + + // MARK: - Recipient Tags + + private static let tagContext = Data("bitchat-courier-tag-v1".utf8) + + /// UTC day number used to rotate recipient tags. + public static func epochDay(for date: Date) -> UInt32 { + UInt32(max(0, date.timeIntervalSince1970) / 86_400) + } + + /// Rotating recipient hint for a given day. Computable only by parties + /// who already know the recipient's Noise static public key. + public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data { + var message = tagContext + withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) } + let mac = HMAC.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey)) + return Data(mac).prefix(tagLength) + } + + /// Tags to test when checking whether an envelope is addressed to a peer. + /// Covers the adjacent days so envelopes sealed near midnight (or across + /// modest clock skew) still match while being carried. + public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] { + let day = epochDay(for: date) + return [day == 0 ? 0 : day - 1, day, day + 1].map { + recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0) + } + } +} diff --git a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift index 8f06ff74..32fa4e76 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/DeliveryStatus.swift @@ -11,17 +11,20 @@ import struct Foundation.Date public enum DeliveryStatus: Codable, Equatable, Hashable { case sending case sent // Left our device + case carried // Sealed envelope handed to a courier; best-effort physical delivery case delivered(to: String, at: Date) // Confirmed by recipient case read(by: String, at: Date) // Seen by recipient case failed(reason: String) case partiallyDelivered(reached: Int, total: Int) // For rooms - + public var displayText: String { switch self { case .sending: return "Sending..." case .sent: return "Sent" + case .carried: + return "Carried by a friend" case .delivered(let nickname, _): return "Delivered to \(nickname)" case .read(let nickname, _): diff --git a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift index 5c54781f..ceac0cd0 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift @@ -12,8 +12,9 @@ public enum MessageType: UInt8 { // Public messages (unencrypted) case announce = 0x01 // "I'm here" with nickname - case message = 0x02 // Public chat message + case message = 0x02 // Public chat message case leave = 0x03 // "I'm leaving" + case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer case requestSync = 0x21 // GCS filter-based sync request (local-only) // Noise encryption @@ -29,6 +30,7 @@ public enum MessageType: UInt8 { case .announce: return "announce" case .message: return "message" case .leave: return "leave" + case .courierEnvelope: return "courierEnvelope" case .requestSync: return "requestSync" case .noiseHandshake: return "noiseHandshake" case .noiseEncrypted: return "noiseEncrypted" diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift new file mode 100644 index 00000000..a64551ab --- /dev/null +++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift @@ -0,0 +1,122 @@ +// +// CourierEnvelopeTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Testing +import Foundation +@testable import BitFoundation + +struct CourierEnvelopeTests { + + private func makeEnvelope( + tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength), + expiry: UInt64 = 1_900_000_000_000, + ciphertext: Data = Data(repeating: 0x42, count: 128) + ) -> CourierEnvelope { + CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext) + } + + // MARK: - Codec + + @Test func roundTrip() throws { + let envelope = makeEnvelope() + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + } + + @Test func roundTripAtMaxCiphertextSize() throws { + let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes)) + let encoded = try #require(envelope.encode()) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == envelope) + } + + @Test func encodeRejectsInvalidFields() { + #expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil) + #expect(makeEnvelope(ciphertext: Data()).encode() == nil) + #expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil) + } + + @Test func decodeRejectsMissingFields() throws { + // Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only. + let encoded = try #require(makeEnvelope().encode()) + let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8) + #expect(CourierEnvelope.decode(Data(truncated)) == nil) + } + + @Test func decodeRejectsTruncatedValue() throws { + let encoded = try #require(makeEnvelope().encode()) + #expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil) + } + + @Test func decodeSkipsUnknownTLVs() throws { + var encoded = try #require(makeEnvelope().encode()) + // Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it. + encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD]) + let decoded = try #require(CourierEnvelope.decode(encoded)) + #expect(decoded == makeEnvelope()) + } + + @Test func decodeOffsetSlice() throws { + // Decoder must handle slices with non-zero startIndex. + let encoded = try #require(makeEnvelope().encode()) + let padded = Data([0xFF, 0xFF]) + encoded + let slice = padded.dropFirst(2) + #expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope()) + #expect(CourierEnvelope.decode(slice) == makeEnvelope()) + } + + // MARK: - Expiry + + @Test func expiryComparison() { + let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) + #expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired) + #expect(makeEnvelope(expiry: nowMs - 60_000).isExpired) + #expect(makeEnvelope(expiry: 0).isExpired) + } + + // MARK: - Recipient Tags + + @Test func tagIsDeterministicPerKeyAndDay() { + let key = Data(repeating: 0x11, count: 32) + let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) + let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000) + #expect(tag1 == tag2) + #expect(tag1.count == CourierEnvelope.tagLength) + } + + @Test func tagRotatesAcrossDaysAndKeys() { + let key = Data(repeating: 0x11, count: 32) + let otherKey = Data(repeating: 0x22, count: 32) + let day: UInt32 = 20_000 + #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) + != CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)) + #expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day) + != CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day)) + } + + @Test func candidateTagsCoverAdjacentDays() { + let key = Data(repeating: 0x33, count: 32) + let date = Date(timeIntervalSince1970: 1_750_000_000) + let day = CourierEnvelope.epochDay(for: date) + let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date) + #expect(candidates.count == 3) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1))) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day))) + #expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))) + } + + @Test func sealedYesterdayMatchesToday() { + // An envelope sealed late on day D must still match the recipient on day D+1. + let key = Data(repeating: 0x44, count: 32) + let sealedAt = Date(timeIntervalSince1970: 1_750_000_000) + let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60) + let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt)) + #expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag)) + } +}