Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers

When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-12 11:13:16 +02:00
co-authored by Claude Fable 5
parent 266827ceff
commit 5aaa209020
23 changed files with 1451 additions and 16 deletions
+7 -1
View File
@@ -93,6 +93,7 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key case IK // Initiator knows responder's static key
case NK // Anonymous initiator case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
} }
enum NoiseRole { enum NoiseRole {
@@ -594,7 +595,7 @@ final class NoiseHandshakeState {
switch pattern { switch pattern {
case .XX: case .XX:
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK, .X:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic { } else if role == .responder, let localStatic = localStaticPublic {
@@ -897,6 +898,7 @@ extension NoisePattern {
case .XX: return "XX" case .XX: return "XX"
case .IK: return "IK" case .IK: return "IK"
case .NK: return "NK" case .NK: return "NK"
case .X: return "X"
} }
} }
@@ -918,6 +920,10 @@ extension NoisePattern {
[.e, .es], // -> e, es [.e, .es], // -> e, es
[.e, .ee] // <- e, ee [.e, .ee] // <- e, ee
] ]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
} }
} }
} }
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false return false
} }
} }
+159 -2
View File
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication // 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator() 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 var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -860,6 +874,10 @@ final class BLEService: NSObject {
} else { } else {
packetToSend = packet packetToSend = packet
} }
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -2439,7 +2457,129 @@ extension BLEService {
ttl: messageTTL 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) // MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T { private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2924,7 +3064,10 @@ extension BLEService {
case .fileTransfer: case .fileTransfer:
handleFileTransfer(packet, from: senderID) handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: senderID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
@@ -2988,6 +3131,20 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
announceHandler.handle(packet, from: 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 /// Builds the announce handler environment. All queue hops stay here so
+217
View File
@@ -0,0 +1,217 @@
//
// CourierStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import 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")
}
}
+63 -1
View File
@@ -2,11 +2,33 @@ import BitLogger
import BitFoundation import BitFoundation
import Foundation 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.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a /// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -14,6 +36,12 @@ final class MessageRouter {
/// stale "sending/sent" state forever. /// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? 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 // Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage { private struct QueuedMessage {
let content: String let content: String
@@ -31,10 +59,17 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never // 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). // get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8 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.transports = transports
self.now = now self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -94,6 +129,33 @@ final class MessageRouter {
unsent.sendAttempts = 0 unsent.sendAttempts = 0
enqueue(unsent, for: peerID) 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) 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
}
} }
} }
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation 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) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered: case .read, .delivered:
externalReceipts.insert(message.id) externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent: case .failed, .partiallyDelivered, .sending, .sent, .carried:
break break
} }
} }
+7
View File
@@ -95,6 +95,12 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ 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) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(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 sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
+3
View File
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5 case .fragment: return 5
case .requestSync: return 6 case .requestSync: return 6
case .fileTransfer: return 7 case .fileTransfer: return 7
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
} }
} }
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1 case .failed: return 1
case .sending: return 2 case .sending: return 2
case .sent: return 3 case .sent: return 3
case .partiallyDelivered: return 4 case .carried: return 4
case .delivered: return 5 case .partiallyDelivered: return 5
case .read: return 6 case .delivered: return 6
case .read: return 7
} }
} }
} }
+3
View File
@@ -1142,6 +1142,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above // Identity manager has cleared persisted identity data above
// Clear autocomplete state // Clear autocomplete state
@@ -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.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel) viewModel.participantTracker.configure(context: viewModel)
@@ -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 { static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
String( String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"), format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
@@ -68,6 +72,12 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .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, _): case .delivered(let nickname, _):
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@@ -111,6 +121,7 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.sending, .sending,
.sent, .sent,
.carried,
.delivered(to: "John Doe", at: Date()), .delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()), .read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"), .failed(reason: "Offline"),
+23
View File
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "bitchat/") Text(verbatim: "bitchat/")
@@ -78,6 +81,23 @@ struct ContentHeaderView: View {
}() }()
HStack(spacing: 2) { 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 { if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
@@ -192,6 +212,9 @@ struct ContentHeaderView: View {
} }
.frame(height: headerHeight) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
+1 -1
View File
@@ -90,7 +90,7 @@ struct MediaMessageView: View {
isSending = true isSending = true
progress = Double(reached) / Double(total) progress = Double(reached) / Double(total)
} }
case .sent, .read, .delivered, .failed: case .sent, .carried, .read, .delivered, .failed:
break break
} }
} }
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor @Test @MainActor
func statusRank_orderingIsCorrect() async { func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages // This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read // failed < sending < sent < carried < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.failed(reason: "test"), .failed(reason: "test"),
.sending, .sending,
.sent, .sent,
.carried,
.partiallyDelivered(reached: 1, total: 3), .partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()), .delivered(to: "B", at: Date()),
.read(by: "C", at: Date()) .read(by: "C", at: Date())
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0) case .failed: #expect(index == 0)
case .sending: #expect(index == 1) case .sending: #expect(index == 1)
case .sent: #expect(index == 2) case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3) case .carried: #expect(index == 3)
case .delivered: #expect(index == 4) case .partiallyDelivered: #expect(index == 4)
case .read: #expect(index == 5) case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
} }
} }
} }
+173
View File
@@ -0,0 +1,173 @@
//
// CourierStoreTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct CourierStoreTests {
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
private func makeStore(now: Date = baseDate) -> CourierStore {
CourierStore(persistsToDisk: false, now: { now })
}
/// Store whose clock can be advanced by tests.
private final class Clock {
var now: Date
init(_ now: Date) { self.now = now }
}
private func makeEnvelope(
recipientKey: Data = Data(repeating: 0xB0, count: 32),
sealedAt: Date = baseDate,
lifetime: TimeInterval = 60 * 60,
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
) -> CourierEnvelope {
CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: sealedAt)
),
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
ciphertext: ciphertext
)
}
private let depositorA = Data(repeating: 0xA1, count: 32)
private let depositorB = Data(repeating: 0xA2, count: 32)
// MARK: - Deposit and handover
@Test func depositThenTakeForRecipient() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
let taken = store.takeEnvelopes(for: recipientKey)
#expect(taken == [envelope])
// Handover removes the envelope.
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
@Test func takeIgnoresOtherRecipients() {
let store = makeStore()
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
store.deposit(envelope, from: depositorA)
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
}
@Test func duplicateDepositIsIdempotent() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(store.deposit(envelope, from: depositorA))
#expect(store.deposit(envelope, from: depositorA))
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
}
// MARK: - Validity
@Test func rejectsExpiredAndOversizedAndMalformed() {
let store = makeStore()
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
#expect(!store.deposit(expired, from: depositorA))
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
#expect(!store.deposit(oversized, from: depositorA))
let badTag = CourierEnvelope(
recipientTag: Data(repeating: 0, count: 4),
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
ciphertext: Data(repeating: 1, count: 16)
)
#expect(!store.deposit(badTag, from: depositorA))
}
@Test func rejectsExpiryBeyondPolicyLifetime() {
let store = makeStore()
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
#expect(!store.deposit(pinned, from: depositorA))
}
// MARK: - Quotas
@Test func perDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA))
// A different depositor still has room.
#expect(store.deposit(makeEnvelope(), from: depositorB))
}
@Test func totalQuotaEvictsOldestFirst() {
let store = makeStore()
let firstRecipient = Data(repeating: 0xD0, count: 32)
let first = makeEnvelope(recipientKey: firstRecipient)
store.deposit(first, from: depositorA)
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
var deposited = 1
var depositorByte: UInt8 = 1
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
#expect(store.deposit(makeEnvelope(), from: depositor))
deposited += 1
}
depositorByte += 1
}
// The first envelope was evicted to make room.
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
}
// MARK: - Expiry over time
@Test func expiredEnvelopesAreNotHandedOver() {
let clock = Clock(Self.baseDate)
let store = CourierStore(persistsToDisk: false, now: { clock.now })
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
clock.now = Self.baseDate.addingTimeInterval(7200)
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Panic wipe
@Test func wipeDropsEverything() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
store.wipe()
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
}
// MARK: - Persistence
@Test func persistsAndReloadsAcrossInstances() throws {
// Uses the real on-disk location; clean up around the test.
let first = CourierStore(persistsToDisk: true, now: { Self.baseDate })
defer { first.wipe() }
first.wipe()
let recipientKey = Data(repeating: 0xE0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey)
#expect(first.deposit(envelope, from: depositorA))
let second = CourierStore(persistsToDisk: true, now: { Self.baseDate })
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
}
}
@@ -0,0 +1,329 @@
//
// CourierEndToEndTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Testing
import Foundation
import 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)
}
}
+107
View File
@@ -0,0 +1,107 @@
//
// NoiseCourierTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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))
}
}
@@ -0,0 +1,147 @@
//
// CourierEnvelope.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<T: FixedWidthInteger>(_ 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..<data.index(cursor, offsetBy: length)]
cursor = data.index(cursor, offsetBy: length)
switch TLVType(rawValue: typeRaw) {
case .recipientTag:
guard length == tagLength else { return nil }
recipientTag = Data(value)
case .expiry:
guard length == 8 else { return nil }
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .ciphertext:
guard length > 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<SHA256>.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)
}
}
}
@@ -11,17 +11,20 @@ import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable { public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending case sending
case sent // Left our device 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 delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient case read(by: String, at: Date) // Seen by recipient
case failed(reason: String) case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String { public var displayText: String {
switch self { switch self {
case .sending: case .sending:
return "Sending..." return "Sending..."
case .sent: case .sent:
return "Sent" return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _): case .delivered(let nickname, _):
return "Delivered to \(nickname)" return "Delivered to \(nickname)"
case .read(let nickname, _): case .read(let nickname, _):
@@ -12,8 +12,9 @@
public enum MessageType: UInt8 { public enum MessageType: UInt8 {
// Public messages (unencrypted) // Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname 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 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) case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync" case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
@@ -0,0 +1,122 @@
//
// CourierEnvelopeTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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))
}
}